From 2039492baffce52fa88f24cd157e55b1def17030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 26 Feb 2026 12:58:37 +0100 Subject: [PATCH 01/15] initial migration commit --- crates/defguard/src/main.rs | 6 +- .../src/db/models/migration_wizard.rs | 66 +++++++++++++ crates/defguard_core/src/db/models/mod.rs | 1 + crates/defguard_core/src/handlers/mod.rs | 1 + crates/defguard_core/src/handlers/wizard.rs | 63 ++++++++++++ crates/defguard_core/src/lib.rs | 6 ++ crates/defguard_setup/src/db/mod.rs | 1 + crates/defguard_setup/src/db/models/mod.rs | 1 + .../src/db/models/wizard_flags.rs | 98 +++++++++++++++++++ .../initial_wizard.rs} | 0 crates/defguard_setup/src/handlers/mod.rs | 1 + crates/defguard_setup/src/lib.rs | 1 + crates/defguard_setup/src/setup.rs | 11 ++- ...25142454_[2.0.0]_migration_wizard.down.sql | 1 + ...0225142454_[2.0.0]_migration_wizard.up.sql | 11 +++ 15 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 crates/defguard_core/src/db/models/migration_wizard.rs create mode 100644 crates/defguard_core/src/handlers/wizard.rs create mode 100644 crates/defguard_setup/src/db/mod.rs create mode 100644 crates/defguard_setup/src/db/models/mod.rs create mode 100644 crates/defguard_setup/src/db/models/wizard_flags.rs rename crates/defguard_setup/src/{handlers.rs => handlers/initial_wizard.rs} (100%) create mode 100644 crates/defguard_setup/src/handlers/mod.rs create mode 100644 migrations/20260225142454_[2.0.0]_migration_wizard.down.sql create mode 100644 migrations/20260225142454_[2.0.0]_migration_wizard.up.sql diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index b4287f01d..855d0b284 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -33,7 +33,7 @@ use defguard_event_router::{RouterReceiverSet, run_event_router}; use defguard_gateway_manager::{GatewayManager, GatewayTxSet}; use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; use defguard_session_manager::{events::SessionManagerEvent, run_session_manager}; -use defguard_setup::setup::run_setup_web_server; +use defguard_setup::{db::models::wizard_flags::WizardFlags, setup::run_setup_web_server}; use defguard_vpn_stats_purge::run_periodic_stats_purge; use secrecy::ExposeSecret; use tokio::sync::{ @@ -94,13 +94,15 @@ async fn main() -> Result<(), anyhow::Error> { info!("Using HMAC OpenID signing key"); } + let wizard_flags = WizardFlags::init(&pool).await?; + // initialize default settings Settings::init_defaults(&pool).await?; // initialize global settings struct initialize_current_settings(&pool).await?; let mut settings = Settings::get_current_settings(); - if !settings.initial_setup_completed { + if wizard_flags.initial_wizard_in_progress && !wizard_flags.initial_wizard_completed { if let Err(err) = run_setup_web_server(pool.clone(), config.http_bind_address, config.http_port).await { diff --git a/crates/defguard_core/src/db/models/migration_wizard.rs b/crates/defguard_core/src/db/models/migration_wizard.rs new file mode 100644 index 000000000..a41a9c2bf --- /dev/null +++ b/crates/defguard_core/src/db/models/migration_wizard.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgExecutor; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub(crate) enum MigrationWizardStep { + #[default] + Welcome, + GeneralConfiguration, + CertificateAuthority, + CertificateSummary, + EdgeComponent, + EdgeComponentAdaptation, + Confirmation, + LocationMigration, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MigrationWizardLocationState { + pub(crate) locations: Vec, + pub(crate) current_location: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrationWizardState { + pub location_state: Option, +} + +impl MigrationWizardState { + pub(crate) async fn get<'e, E>(executor: E) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + let state: Option = sqlx::query_scalar( + "SELECT migration_wizard_state + FROM wizard + LIMIT 1", + ) + .fetch_optional(executor) + .await? + .flatten(); + + state + .map(serde_json::from_value) + .transpose() + .map_err(|error| sqlx::Error::Decode(Box::new(error))) + } + + pub(crate) async fn save<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + let state = + serde_json::to_value(self).map_err(|error| sqlx::Error::Decode(Box::new(error)))?; + + sqlx::query( + "UPDATE wizard + SET migration_wizard_state = $1 + WHERE is_singleton = TRUE", + ) + .bind(state) + .execute(executor) + .await?; + + Ok(()) + } +} diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index b6efe1b57..7daf2d4ff 100644 --- a/crates/defguard_core/src/db/models/mod.rs +++ b/crates/defguard_core/src/db/models/mod.rs @@ -1,3 +1,4 @@ pub mod activity_log; pub mod enrollment; +pub mod migration_wizard; pub mod webhook; diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index b530532ab..f711d482b 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -50,6 +50,7 @@ pub(crate) mod updates; pub mod user; pub(crate) mod webhooks; pub mod wireguard; +pub(crate) mod wizard; pub mod worker; pub(crate) mod yubikey; diff --git a/crates/defguard_core/src/handlers/wizard.rs b/crates/defguard_core/src/handlers/wizard.rs new file mode 100644 index 000000000..c8c71c75f --- /dev/null +++ b/crates/defguard_core/src/handlers/wizard.rs @@ -0,0 +1,63 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::FromRow; + +use super::{ApiResponse, ApiResult}; +use crate::{ + appstate::AppState, auth::AdminRole, db::models::migration_wizard::MigrationWizardState, +}; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub(crate) struct WizardFlags { + pub migration_wizard_needed: bool, + pub migration_wizard_in_progress: bool, + pub migration_wizard_completed: bool, + pub initial_wizard_completed: bool, + pub initial_wizard_in_progress: bool, +} + +pub(crate) async fn get_wizard_flags( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let flags = sqlx::query_as!( + WizardFlags, + "SELECT + migration_wizard_needed, + migration_wizard_in_progress, + migration_wizard_completed, + initial_wizard_completed, + initial_wizard_in_progress + FROM wizard + LIMIT 1" + ) + .fetch_one(&appstate.pool) + .await?; + + Ok(ApiResponse::json(flags, StatusCode::OK)) +} + +pub(crate) async fn get_migration_wizard_state( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let migration_state = MigrationWizardState::get(&appstate.pool).await?; + + Ok(ApiResponse::new( + json!({ + "migration_state": migration_state + }), + StatusCode::OK, + )) +} + +pub(crate) async fn update_migration_wizard_state( + _role: AdminRole, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + data.save(&appstate.pool).await?; + + Ok(ApiResponse::new(json!({}), StatusCode::OK)) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index bc6ae24ca..a5c0cd882 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -49,6 +49,7 @@ use handlers::{ }, updates::check_new_version, wireguard::all_gateways_status, + wizard::{get_migration_wizard_state, get_wizard_flags, update_migration_wizard_state}, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -239,6 +240,11 @@ pub fn build_webapp( .route("/ssh_authorized_keys", get(get_authorized_keys)) .route("/api-docs", get(openapi)) .route("/updates", get(check_new_version)) + .route("/wizard", get(get_wizard_flags)) + .route( + "/wizard/migration", + get(get_migration_wizard_state).put(update_migration_wizard_state), + ) // /auth .route("/auth", post(authenticate)) .route("/auth/logout", post(logout)) diff --git a/crates/defguard_setup/src/db/mod.rs b/crates/defguard_setup/src/db/mod.rs new file mode 100644 index 000000000..c446ac883 --- /dev/null +++ b/crates/defguard_setup/src/db/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/crates/defguard_setup/src/db/models/mod.rs b/crates/defguard_setup/src/db/models/mod.rs new file mode 100644 index 000000000..2efc7e284 --- /dev/null +++ b/crates/defguard_setup/src/db/models/mod.rs @@ -0,0 +1 @@ +pub mod wizard_flags; diff --git a/crates/defguard_setup/src/db/models/wizard_flags.rs b/crates/defguard_setup/src/db/models/wizard_flags.rs new file mode 100644 index 000000000..24f2e8f34 --- /dev/null +++ b/crates/defguard_setup/src/db/models/wizard_flags.rs @@ -0,0 +1,98 @@ +use sqlx::{PgExecutor, prelude::FromRow}; + +#[derive(Debug, FromRow)] +pub struct WizardFlags { + pub migration_wizard_needed: bool, + pub migration_wizard_in_progress: bool, + pub migration_wizard_completed: bool, + pub initial_wizard_completed: bool, + pub initial_wizard_in_progress: bool, +} + +impl WizardFlags { + pub async fn save<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + sqlx::query( + "UPDATE wizard + SET + migration_wizard_needed = $1, + migration_wizard_in_progress = $2, + migration_wizard_completed = $3, + initial_wizard_in_progress = $4, + initial_wizard_completed = $5 + WHERE is_singleton = TRUE", + ) + .bind(self.migration_wizard_needed) + .bind(self.migration_wizard_in_progress) + .bind(self.migration_wizard_completed) + .bind(self.initial_wizard_in_progress) + .bind(self.initial_wizard_completed) + .execute(executor) + .await?; + + Ok(()) + } + + pub async fn get<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e>, + { + sqlx::query_as!( + Self, + "SELECT + migration_wizard_needed, + migration_wizard_in_progress, + migration_wizard_completed, + initial_wizard_in_progress, + initial_wizard_completed + FROM wizard + LIMIT 1" + ) + .fetch_one(executor) + .await + } + + pub async fn init<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e> + Copy, + { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM wizard") + .fetch_one(executor) + .await?; + + if count == 0 { + let is_fresh_instance: bool = sqlx::query_scalar( + "SELECT + (SELECT COUNT(*) FROM \"user\") = 0 + AND (SELECT COUNT(*) FROM wireguard_network) = 0 + AND (SELECT COUNT(*) FROM \"device\") = 0", + ) + .fetch_one(executor) + .await?; + + sqlx::query( + "INSERT INTO wizard ( + migration_wizard_needed, + migration_wizard_in_progress, + migration_wizard_completed, + initial_wizard_in_progress, + initial_wizard_completed + ) VALUES (FALSE, FALSE, FALSE, $1, FALSE)", + ) + .bind(is_fresh_instance) + .execute(executor) + .await?; + + return Ok(Self { + migration_wizard_needed: false, + migration_wizard_in_progress: false, + migration_wizard_completed: false, + initial_wizard_in_progress: is_fresh_instance, + initial_wizard_completed: false, + }); + } + Self::get(executor).await + } +} diff --git a/crates/defguard_setup/src/handlers.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs similarity index 100% rename from crates/defguard_setup/src/handlers.rs rename to crates/defguard_setup/src/handlers/initial_wizard.rs diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs new file mode 100644 index 000000000..efd8e8296 --- /dev/null +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod initial_wizard; diff --git a/crates/defguard_setup/src/lib.rs b/crates/defguard_setup/src/lib.rs index 97bcacc11..5d1cc01be 100644 --- a/crates/defguard_setup/src/lib.rs +++ b/crates/defguard_setup/src/lib.rs @@ -1,2 +1,3 @@ +pub mod db; pub mod handlers; pub mod setup; diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs index 96091fb18..e7203532e 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup.rs @@ -18,15 +18,22 @@ use defguard_core::{ }; use defguard_web_ui::{index, svg, web_asset}; use semver::Version; -use sqlx::PgPool; +use sqlx::{PgExecutor, PgPool}; use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; -use crate::handlers::{ +use crate::handlers::initial_wizard::{ create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, setup_session, upload_ca, }; +pub async fn is_initial_setup_needed<'e, E>(executor: E) -> Result +where + E: PgExecutor<'e>, +{ + todo!() +} + pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { let failed_logins = Arc::new(Mutex::new(FailedLoginMap::new())); Router::<()>::new() diff --git a/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql b/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql new file mode 100644 index 000000000..b9be5a828 --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql @@ -0,0 +1 @@ +DROP TABLE wizard; diff --git a/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql b/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql new file mode 100644 index 000000000..ea2af6ad2 --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE wizard ( + migration_wizard_needed BOOLEAN NOT NULL DEFAULT FALSE, + migration_wizard_state JSONB DEFAULT NULL, + migration_wizard_completed BOOLEAN NOT NULL DEFAULT FALSE, + migration_wizard_in_progress BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_completed BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_in_progress BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_state JSONB DEFAULT NULL, + -- Constrain to a single row + is_singleton BOOLEAN NOT NULL DEFAULT TRUE PRIMARY KEY CHECK (is_singleton) +); From 18ffd09c625004dd7bde4f1ba4dcd516ea6a75cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 26 Feb 2026 13:05:36 +0100 Subject: [PATCH 02/15] set migration on flags init --- crates/defguard_setup/src/db/models/wizard_flags.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/defguard_setup/src/db/models/wizard_flags.rs b/crates/defguard_setup/src/db/models/wizard_flags.rs index 24f2e8f34..91694e57c 100644 --- a/crates/defguard_setup/src/db/models/wizard_flags.rs +++ b/crates/defguard_setup/src/db/models/wizard_flags.rs @@ -72,6 +72,8 @@ impl WizardFlags { .fetch_one(executor) .await?; + let is_migration_needed = !is_fresh_instance; + sqlx::query( "INSERT INTO wizard ( migration_wizard_needed, @@ -79,14 +81,15 @@ impl WizardFlags { migration_wizard_completed, initial_wizard_in_progress, initial_wizard_completed - ) VALUES (FALSE, FALSE, FALSE, $1, FALSE)", + ) VALUES ($1, FALSE, FALSE, $2, FALSE)", ) + .bind(is_migration_needed) .bind(is_fresh_instance) .execute(executor) .await?; return Ok(Self { - migration_wizard_needed: false, + migration_wizard_needed: is_migration_needed, migration_wizard_in_progress: false, migration_wizard_completed: false, initial_wizard_in_progress: is_fresh_instance, From 8876f8b7f66f92980f9f6c5ed04299fb2c2eed20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 26 Feb 2026 13:57:40 +0100 Subject: [PATCH 03/15] add session info endpoint --- crates/defguard_core/src/db/models/mod.rs | 1 + .../src/db/models/wizard_flags.rs | 3 +- crates/defguard_core/src/handlers/mod.rs | 1 + .../src/handlers/session_info.rs | 71 +++++++++++++++++++ crates/defguard_core/src/handlers/wizard.rs | 29 ++------ crates/defguard_core/src/lib.rs | 2 + crates/defguard_setup/src/db/models/mod.rs | 2 +- 7 files changed, 82 insertions(+), 27 deletions(-) rename crates/{defguard_setup => defguard_core}/src/db/models/wizard_flags.rs (97%) create mode 100644 crates/defguard_core/src/handlers/session_info.rs diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index 7daf2d4ff..74616bc53 100644 --- a/crates/defguard_core/src/db/models/mod.rs +++ b/crates/defguard_core/src/db/models/mod.rs @@ -2,3 +2,4 @@ pub mod activity_log; pub mod enrollment; pub mod migration_wizard; pub mod webhook; +pub mod wizard_flags; diff --git a/crates/defguard_setup/src/db/models/wizard_flags.rs b/crates/defguard_core/src/db/models/wizard_flags.rs similarity index 97% rename from crates/defguard_setup/src/db/models/wizard_flags.rs rename to crates/defguard_core/src/db/models/wizard_flags.rs index 91694e57c..1c76d80b1 100644 --- a/crates/defguard_setup/src/db/models/wizard_flags.rs +++ b/crates/defguard_core/src/db/models/wizard_flags.rs @@ -1,6 +1,7 @@ +use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, prelude::FromRow}; -#[derive(Debug, FromRow)] +#[derive(Debug, Serialize, Deserialize, FromRow)] pub struct WizardFlags { pub migration_wizard_needed: bool, pub migration_wizard_in_progress: bool, diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index f711d482b..c82707d69 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -42,6 +42,7 @@ pub mod openid_clients; pub mod openid_flow; pub(crate) mod pagination; pub mod proxy; +pub(crate) mod session_info; pub mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod static_ips; diff --git a/crates/defguard_core/src/handlers/session_info.rs b/crates/defguard_core/src/handlers/session_info.rs new file mode 100644 index 000000000..439167231 --- /dev/null +++ b/crates/defguard_core/src/handlers/session_info.rs @@ -0,0 +1,71 @@ +use axum::{extract::State, http::StatusCode}; +use defguard_common::db::models::User; +use serde::Serialize; + +use super::{ApiResponse, ApiResult}; +use crate::{ + appstate::AppState, auth::SessionExtractor, db::models::wizard_flags::WizardFlags, + error::WebError, +}; + +#[derive(Serialize)] +struct SessionInfoResponse { + authorized: bool, + wizard_flags: Option, +} + +pub(crate) async fn get_session_info( + State(appstate): State, + session: Result, +) -> ApiResult { + let pool = &appstate.pool; + let flags = WizardFlags::get(pool).await?; + + let Ok(SessionExtractor(session)) = session else { + if flags.initial_wizard_in_progress { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + wizard_flags: Some(flags), + }, + StatusCode::OK, + )); + } else { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + wizard_flags: None, + }, + StatusCode::OK, + )); + } + }; + + let Some(user) = User::find_by_id(pool, session.user_id).await? else { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + wizard_flags: None, + }, + StatusCode::OK, + )); + }; + + if !user.is_admin(pool).await? { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: true, + wizard_flags: None, + }, + StatusCode::OK, + )); + } + + Ok(ApiResponse::json( + SessionInfoResponse { + authorized: true, + wizard_flags: Some(flags), + }, + StatusCode::OK, + )) +} diff --git a/crates/defguard_core/src/handlers/wizard.rs b/crates/defguard_core/src/handlers/wizard.rs index c8c71c75f..ee1dc5aac 100644 --- a/crates/defguard_core/src/handlers/wizard.rs +++ b/crates/defguard_core/src/handlers/wizard.rs @@ -1,39 +1,18 @@ use axum::{Json, extract::State, http::StatusCode}; -use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::FromRow; use super::{ApiResponse, ApiResult}; use crate::{ - appstate::AppState, auth::AdminRole, db::models::migration_wizard::MigrationWizardState, + appstate::AppState, + auth::AdminRole, + db::models::{migration_wizard::MigrationWizardState, wizard_flags::WizardFlags}, }; -#[derive(Debug, Serialize, Deserialize, FromRow)] -pub(crate) struct WizardFlags { - pub migration_wizard_needed: bool, - pub migration_wizard_in_progress: bool, - pub migration_wizard_completed: bool, - pub initial_wizard_completed: bool, - pub initial_wizard_in_progress: bool, -} - pub(crate) async fn get_wizard_flags( _role: AdminRole, State(appstate): State, ) -> ApiResult { - let flags = sqlx::query_as!( - WizardFlags, - "SELECT - migration_wizard_needed, - migration_wizard_in_progress, - migration_wizard_completed, - initial_wizard_completed, - initial_wizard_in_progress - FROM wizard - LIMIT 1" - ) - .fetch_one(&appstate.pool) - .await?; + let flags = WizardFlags::get(&appstate.pool).await?; Ok(ApiResponse::json(flags, StatusCode::OK)) } diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index a5c0cd882..79a249964 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -43,6 +43,7 @@ use handlers::{ find_available_ips, get_network_device, list_network_devices, modify_network_device, start_network_device_setup, start_network_device_setup_for_device, }, + session_info::get_session_info, ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, rename_authentication_key, @@ -237,6 +238,7 @@ pub fn build_webapp( Router::new() .route("/health", get(health_check)) .route("/info", get(get_app_info)) + .route("/session-info", get(get_session_info)) .route("/ssh_authorized_keys", get(get_authorized_keys)) .route("/api-docs", get(openapi)) .route("/updates", get(check_new_version)) diff --git a/crates/defguard_setup/src/db/models/mod.rs b/crates/defguard_setup/src/db/models/mod.rs index 2efc7e284..8b1378917 100644 --- a/crates/defguard_setup/src/db/models/mod.rs +++ b/crates/defguard_setup/src/db/models/mod.rs @@ -1 +1 @@ -pub mod wizard_flags; + From b80c10157a799f605d6a3d8c22d23ad190e0d9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 26 Feb 2026 14:21:37 +0100 Subject: [PATCH 04/15] fix clippy --- crates/defguard/src/main.rs | 4 ++-- crates/defguard_core/src/db/models/migration_wizard.rs | 1 + crates/defguard_setup/src/setup.rs | 9 +-------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 855d0b284..2b397163e 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -16,7 +16,7 @@ use defguard_common::{ }; use defguard_core::{ auth::failed_login::FailedLoginMap, - db::AppEvent, + db::{AppEvent, models::wizard_flags::WizardFlags}, enterprise::{ activity_log_stream::activity_log_stream_manager::run_activity_log_stream_manager, license::{License, run_periodic_license_check, set_cached_license}, @@ -33,7 +33,7 @@ use defguard_event_router::{RouterReceiverSet, run_event_router}; use defguard_gateway_manager::{GatewayManager, GatewayTxSet}; use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; use defguard_session_manager::{events::SessionManagerEvent, run_session_manager}; -use defguard_setup::{db::models::wizard_flags::WizardFlags, setup::run_setup_web_server}; +use defguard_setup::setup::run_setup_web_server; use defguard_vpn_stats_purge::run_periodic_stats_purge; use secrecy::ExposeSecret; use tokio::sync::{ diff --git a/crates/defguard_core/src/db/models/migration_wizard.rs b/crates/defguard_core/src/db/models/migration_wizard.rs index a41a9c2bf..237cb5a19 100644 --- a/crates/defguard_core/src/db/models/migration_wizard.rs +++ b/crates/defguard_core/src/db/models/migration_wizard.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgExecutor; +#[allow(dead_code)] #[derive(Serialize, Deserialize, Debug, Default)] pub(crate) enum MigrationWizardStep { #[default] diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs index e7203532e..914611126 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup.rs @@ -18,7 +18,7 @@ use defguard_core::{ }; use defguard_web_ui::{index, svg, web_asset}; use semver::Version; -use sqlx::{PgExecutor, PgPool}; +use sqlx::PgPool; use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; @@ -27,13 +27,6 @@ use crate::handlers::initial_wizard::{ upload_ca, }; -pub async fn is_initial_setup_needed<'e, E>(executor: E) -> Result -where - E: PgExecutor<'e>, -{ - todo!() -} - pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { let failed_logins = Arc::new(Mutex::new(FailedLoginMap::new())); Router::<()>::new() From f219d61496497003cdba68eeed1be1880c7df949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 26 Feb 2026 14:58:32 +0100 Subject: [PATCH 05/15] sqlx query for offline --- ...c844a983d2f0697a7b937a94e3f00d0c7a830.json | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .sqlx/query-9042a72c21aa8c26cd187c2d336c844a983d2f0697a7b937a94e3f00d0c7a830.json diff --git a/.sqlx/query-9042a72c21aa8c26cd187c2d336c844a983d2f0697a7b937a94e3f00d0c7a830.json b/.sqlx/query-9042a72c21aa8c26cd187c2d336c844a983d2f0697a7b937a94e3f00d0c7a830.json new file mode 100644 index 000000000..228b97e2a --- /dev/null +++ b/.sqlx/query-9042a72c21aa8c26cd187c2d336c844a983d2f0697a7b937a94e3f00d0c7a830.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n migration_wizard_needed,\n migration_wizard_in_progress,\n migration_wizard_completed,\n initial_wizard_in_progress,\n initial_wizard_completed\n FROM wizard\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "migration_wizard_needed", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "migration_wizard_in_progress", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "migration_wizard_completed", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "initial_wizard_in_progress", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "initial_wizard_completed", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "9042a72c21aa8c26cd187c2d336c844a983d2f0697a7b937a94e3f00d0c7a830" +} From 36fafc59fed156c8c312aa72b1aa0b02c3505b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Fri, 27 Feb 2026 15:16:07 +0100 Subject: [PATCH 06/15] migration wizard ui init - copy existing steps --- crates/defguard/src/main.rs | 4 - web/messages/en/migration_wizard.json | 46 +++ web/project.inlang/settings.json | 7 +- .../MigrationWizardPage.tsx | 98 +++++++ .../steps/MigrationWizardCAStep.tsx | 265 ++++++++++++++++++ .../steps/MigrationWizardCASummaryStep.tsx | 119 ++++++++ .../steps/MigrationWizardConfirmationStep.tsx | 22 ++ .../steps/MigrationWizardEdgeAdoptionStep.tsx | 188 +++++++++++++ .../MigrationWizardEdgeComponentStep.tsx | 131 +++++++++ ...igrationWizardGeneralConfigurationStep.tsx | 23 ++ .../steps/MigrationWizardStart.tsx | 39 +++ .../store/useMigrationWizardStore.tsx | 84 ++++++ web/src/pages/MigrationWizardPage/style.scss | 40 +++ web/src/pages/MigrationWizardPage/types.ts | 18 ++ web/src/routeTree.gen.ts | 21 ++ web/src/routes/_wizard/migration.tsx | 6 + 16 files changed, 1105 insertions(+), 6 deletions(-) create mode 100644 web/messages/en/migration_wizard.json create mode 100644 web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardCASummaryStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardConfirmationStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardEdgeAdoptionStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardEdgeComponentStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx create mode 100644 web/src/pages/MigrationWizardPage/steps/MigrationWizardStart.tsx create mode 100644 web/src/pages/MigrationWizardPage/store/useMigrationWizardStore.tsx create mode 100644 web/src/pages/MigrationWizardPage/style.scss create mode 100644 web/src/pages/MigrationWizardPage/types.ts create mode 100644 web/src/routes/_wizard/migration.tsx diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 2b397163e..32de64976 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -139,10 +139,6 @@ async fn main() -> Result<(), anyhow::Error> { let incompatible_components: Arc> = Arc::default(); - if settings.ca_cert_der.is_none() || settings.ca_key_der.is_none() { - anyhow::bail!("CA certificate or key were not found in settings, despite completing setup.") - } - // read grpc TLS cert and key let grpc_cert = config .grpc_cert diff --git a/web/messages/en/migration_wizard.json b/web/messages/en/migration_wizard.json new file mode 100644 index 000000000..8c1cee003 --- /dev/null +++ b/web/messages/en/migration_wizard.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "migration_wizard_title": "Migration Wizard", + "migration_wizard_subtitle": "This wizard will guide you through migration-related configuration of your Defguard instance.", + + "migration_wizard_step_general_config_label": "General Configuration", + "migration_wizard_step_general_config_description": "Manage core details and connection parameters for your VPN location.", + "migration_wizard_step_certificate_authority_label": "Certificate Authority", + "migration_wizard_step_certificate_authority_description": "Securing component communication", + "migration_wizard_step_certificate_authority_summary_label": "Certificate Authority Summary", + "migration_wizard_step_certificate_authority_summary_description": "Securing component communication", + "migration_wizard_step_edge_component_label": "Edge Component", + "migration_wizard_step_edge_component_description": "Set up your VPN proxy quickly and ensure secure, optimized traffic flow for your users.", + "migration_wizard_step_edge_adoption_label": "Edge Component Adoption", + "migration_wizard_step_edge_adoption_description": "Review the system’s checks and see if any issues need attention before deployment.", + "migration_wizard_step_confirmation_label": "Confirmation", + "migration_wizard_step_confirmation_description": "Your configuration was successful. You’re all set.", + + "migration_wizard_ca_validity_one_year": "1 year", + "migration_wizard_ca_validity_years": "{years} years", + "migration_wizard_ca_error_common_name_required": "Common name is required", + "migration_wizard_ca_error_email_invalid": "Invalid email address", + "migration_wizard_ca_error_email_required": "Email is required", + "migration_wizard_ca_error_validity_min": "Validity period must be at least 1 year", + "migration_wizard_ca_error_cert_required": "Certificate file is required", + "migration_wizard_ca_error_create_failed": "Failed to create CA. Please review the information and try again.", + "migration_wizard_ca_error_upload_failed": "Failed to upload CA. Please ensure the certificate file is valid and try again.", + "migration_wizard_ca_option_create_title": "Create a certificate authority & configure all Defguard components", + "migration_wizard_ca_option_create_description": "By choosing this option, Defguard will create its own certificate authority and automatically configure all components to use its certificates — no manual setup required.", + "migration_wizard_ca_label_common_name": "Common Name", + "migration_wizard_ca_placeholder_common_name": "Defguard Certificate Authority", + "migration_wizard_ca_label_email": "Email", + "migration_wizard_ca_placeholder_email": "email@example.com", + "migration_wizard_ca_label_validity": "Validity Period", + + "migration_wizard_ca_generated_title": "Certificate Authority Generated", + "migration_wizard_ca_generated_subtitle": "The system created all required certificate files, including the root certificate and private key. You can download these files and continue with the configuration.", + "migration_wizard_ca_download_button": "Download CA certificate", + "migration_wizard_ca_validated_title": "Certificate Authority Validated", + "migration_wizard_ca_validated_subtitle": "Your uploaded Certificate Authority has been successfully validated. All required files were checked and confirmed as correct and ready for use. You can download the validated CA files if needed for your setup.", + "migration_wizard_ca_info_title": "Information extracted from uploaded file", + "migration_wizard_ca_info_label_common_name": "Common Name", + "migration_wizard_ca_info_label_validity": "Validity", + "migration_wizard_ca_validity_unknown": "—", + "migration_wizard_ca_validity_less_than_year": "Less than a year" +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index fa3338bde..61308a6bc 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -1,7 +1,9 @@ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", - "locales": ["en"], + "locales": [ + "en" + ], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js" ], @@ -24,7 +26,8 @@ "./messages/{locale}/location.json", "./messages/{locale}/settings.json", "./messages/{locale}/gateway_wizard.json", - "./messages/{locale}/initial_wizard.json" + "./messages/{locale}/initial_wizard.json", + "./messages/{locale}/migration_wizard.json" ] } } diff --git a/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx b/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx new file mode 100644 index 000000000..f39dda08d --- /dev/null +++ b/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx @@ -0,0 +1,98 @@ +import './style.scss'; +import { type ReactNode, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +import type { + WizardPageStep, + WizardWelcomePageConfig, +} from '../../shared/components/wizard/types'; +import { WizardPage } from '../../shared/components/wizard/WizardPage/WizardPage'; +import { MigrationWizardCAStep } from './steps/MigrationWizardCAStep'; +import { MigrationWizardCASummaryStep } from './steps/MigrationWizardCASummaryStep'; +import { MigrationWizardConfirmationStep } from './steps/MigrationWizardConfirmationStep'; +import { MigrationWizardEdgeAdoptionStep } from './steps/MigrationWizardEdgeAdoptionStep'; +import { MigrationWizardEdgeComponentStep } from './steps/MigrationWizardEdgeComponentStep'; +import { MigrationWizardGeneralConfigurationStep } from './steps/MigrationWizardGeneralConfigurationStep'; +import { MigrationWizardStart } from './steps/MigrationWizardStart'; +import { useMigrationWizardStore } from './store/useMigrationWizardStore'; +import { MigrationWizardStep, type MigrationWizardStepValue } from './types'; + +const welcomePageConfig: WizardWelcomePageConfig = { + title: 'Welcome to Defguard Migration Wizard.', + subtitle: `We've detected your previous version 1.X so email.`, + content: , + media: media.png, + docsText: `We'll guide you through the process step by step. For full details, see the migration guide following the link bellow.`, +} as const; + +export const MigrationWizardPage = () => { + const isWelcome = useMigrationWizardStore((s) => s.isWelcome); + const activeStep = useMigrationWizardStore((s) => s.activeStep); + + const stepsConfig = useMemo( + (): Record => ({ + general: { + id: MigrationWizardStep.General, + order: 1, + label: m.migration_wizard_step_general_config_label(), + description: m.migration_wizard_step_general_config_description(), + }, + ca: { + id: MigrationWizardStep.Ca, + order: 2, + label: m.migration_wizard_step_certificate_authority_label(), + description: m.migration_wizard_step_certificate_authority_description(), + }, + caSummary: { + id: MigrationWizardStep.CaSummary, + order: 3, + label: m.migration_wizard_step_certificate_authority_summary_label(), + description: m.migration_wizard_step_certificate_authority_summary_description(), + }, + edge: { + id: MigrationWizardStep.Edge, + order: 4, + label: m.migration_wizard_step_edge_component_label(), + description: m.migration_wizard_step_edge_component_description(), + }, + edgeAdoption: { + id: MigrationWizardStep.EdgeAdoption, + order: 5, + label: m.migration_wizard_step_edge_adoption_label(), + description: m.migration_wizard_step_edge_adoption_description(), + }, + confirmation: { + id: MigrationWizardStep.Confirmation, + order: 6, + label: m.migration_wizard_step_confirmation_label(), + description: m.migration_wizard_step_confirmation_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + general: , + ca: , + caSummary: , + edge: , + edgeAdoption: , + confirmation: , + }), + [], + ); + + return ( + + {stepsComponents[activeStep]} + + ); +}; diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx new file mode 100644 index 000000000..b98641c86 --- /dev/null +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx @@ -0,0 +1,265 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { BadgeVariant } from '../../../shared/defguard-ui/components/Badge/types'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; +import type { SelectOption } from '../../../shared/defguard-ui/components/Select/types'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; +import { CAOption, type CAOptionType, MigrationWizardStep } from '../types'; + +type ValidityValue = 1 | 2 | 3 | 5 | 10; + +const validityOptions: SelectOption[] = [ + { key: 1, label: m.migration_wizard_ca_validity_one_year(), value: 1 }, + { key: 2, label: m.migration_wizard_ca_validity_years({ years: 2 }), value: 2 }, + { key: 3, label: m.migration_wizard_ca_validity_years({ years: 3 }), value: 3 }, + { key: 5, label: m.migration_wizard_ca_validity_years({ years: 5 }), value: 5 }, + { + key: 10, + label: m.migration_wizard_ca_validity_years({ years: 10 }), + value: 10, + }, +]; + +type CreateCAFormFields = CreateCAStoreValues; + +type CreateCAStoreValues = { + ca_common_name: string; + ca_email: string; + ca_validity_period_years: number; +}; + +type UploadCAFormFields = UploadCAStoreValues; + +type UploadCAStoreValues = { + ca_cert_file: File | null; +}; + +const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +}; + +export const MigrationWizardCAStep = () => { + const setActiveStep = useMigrationWizardStore((s) => s.setActiveStep); + const caOption = useMigrationWizardStore((s) => s.ca_option); + const setCAOption = useCallback((option: CAOptionType) => { + useMigrationWizardStore.setState({ ca_option: option }); + }, []); + + const createCAdefaultValues = useMigrationWizardStore( + useShallow( + (s): CreateCAFormFields => ({ + ca_common_name: s.ca_common_name, + ca_email: s.ca_email, + ca_validity_period_years: s.ca_validity_period_years, + }), + ), + ); + + const uploadCAdefaultValues: UploadCAFormFields = { + ca_cert_file: undefined as unknown as File, + }; + + const createFormSchema = useMemo( + () => + z.object({ + ca_common_name: z + .string() + .min(1, m.migration_wizard_ca_error_common_name_required()), + ca_email: z + .email(m.migration_wizard_ca_error_email_invalid()) + .min(1, m.migration_wizard_ca_error_email_required()), + ca_validity_period_years: z + .number() + .min(1, m.migration_wizard_ca_error_validity_min()), + }), + [], + ); + + const uploadFormSchema = useMemo( + () => + z.object({ + ca_cert_file: z + .file() + .refine((file) => isPresent(file), m.migration_wizard_ca_error_cert_required()), + }), + [], + ); + + const { mutate: createCA, isPending: isCreatingCA } = useMutation({ + mutationFn: api.initial_setup.createCA, + onSuccess: () => { + setActiveStep(MigrationWizardStep.CaSummary); + }, + onError: (error) => { + console.error('Failed to create CA:', error); + Snackbar.error(m.migration_wizard_ca_error_create_failed()); + }, + meta: { + invalidate: ['initial_setup', 'ca'], + }, + }); + + const { mutate: uploadCA, isPending: isUploadingCA } = useMutation({ + mutationFn: api.initial_setup.uploadCA, + onSuccess: () => { + setActiveStep(MigrationWizardStep.CaSummary); + }, + onError: (error) => { + console.error('Failed to upload CA:', error); + Snackbar.error(m.migration_wizard_ca_error_upload_failed()); + }, + meta: { + invalidate: ['initial_setup', 'ca'], + }, + }); + + const createForm = useAppForm({ + defaultValues: createCAdefaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: createFormSchema, + onChange: createFormSchema, + }, + onSubmit: ({ value }) => { + useMigrationWizardStore.setState({ + ca_common_name: value.ca_common_name, + ca_email: value.ca_email, + ca_validity_period_years: value.ca_validity_period_years, + }); + createCA({ + common_name: value.ca_common_name, + email: value.ca_email, + validity_period_years: value.ca_validity_period_years, + }); + }, + }); + + const uploadForm = useAppForm({ + defaultValues: uploadCAdefaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: uploadFormSchema, + onChange: uploadFormSchema, + }, + onSubmit: async ({ value }) => { + if (!value.ca_cert_file) return; + const certContent = await readFileAsText(value.ca_cert_file); + uploadCA({ cert_file: certContent }); + }, + }); + + const CreateCAForm = () => { + const form = createForm; + return ( +
+
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
+
+ ); + }; + + const handleBack = () => { + setActiveStep(MigrationWizardStep.General); + }; + + const handleNext = () => { + if (caOption === CAOption.Create) { + createForm.handleSubmit(); + } else if (caOption === CAOption.UseOwn) { + uploadForm.handleSubmit(); + } + }; + + const isPending = isCreatingCA || isUploadingCA; + + return ( + + setCAOption(CAOption.Create)} + content={m.migration_wizard_ca_option_create_description()} + badge={{ + text: m.misc_recommended(), + variant: BadgeVariant.Success, + }} + > + + {caOption === CAOption.Create && } + + + +