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/28] 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 b4287f01d0..855d0b2845 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 0000000000..a41a9c2bf3 --- /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 b6efe1b578..7daf2d4ffe 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 b530532ab3..f711d482b4 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 0000000000..c8c71c75f8 --- /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 bc6ae24ca6..a5c0cd8823 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 0000000000..c446ac8833 --- /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 0000000000..2efc7e2847 --- /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 0000000000..24f2e8f349 --- /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 0000000000..efd8e82968 --- /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 97bcacc111..5d1cc01be6 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 96091fb180..e7203532e4 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 0000000000..b9be5a8284 --- /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 0000000000..ea2af6ad2f --- /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/28] 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 24f2e8f349..91694e57c7 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/28] 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 7daf2d4ffe..74616bc531 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 91694e57c7..1c76d80b1d 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 f711d482b4..c82707d69d 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 0000000000..4391672317 --- /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 c8c71c75f8..ee1dc5aac0 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 a5c0cd8823..79a2499641 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 2efc7e2847..8b13789179 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/28] 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 855d0b2845..2b397163eb 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 a41a9c2bf3..237cb5a19e 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 e7203532e4..9146111267 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/28] 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 0000000000..228b97e2ae --- /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 48409a78330357d7d46b2aaab54dca26fb996ed0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:32:48 +0100 Subject: [PATCH 06/28] auto adoption wizard 1 --- Cargo.lock | 3 + crates/defguard/src/main.rs | 14 +- crates/defguard_common/src/config.rs | 6 + .../defguard_common/src/db/models/gateway.rs | 6 +- crates/defguard_common/src/db/models/mod.rs | 1 + crates/defguard_common/src/db/models/proxy.rs | 16 +- .../src/db/models/setup_auto_adoption.rs | 78 ++ crates/defguard_common/src/db/models/user.rs | 4 + crates/defguard_common/src/types/proxy.rs | 4 +- .../src/handlers/component_setup.rs | 4 +- crates/defguard_core/src/handlers/gateway.rs | 11 +- crates/defguard_core/src/handlers/proxy.rs | 2 +- crates/defguard_setup/Cargo.toml | 3 + crates/defguard_setup/src/auto_adoption.rs | 883 ++++++++++++++++++ .../src/handlers/auto_wizard.rs | 205 ++++ .../src/handlers/initial_wizard.rs | 62 +- crates/defguard_setup/src/handlers/mod.rs | 1 + crates/defguard_setup/src/lib.rs | 3 +- crates/defguard_setup/src/setup.rs | 12 +- crates/defguard_setup/src/setup_server.rs | 100 ++ crates/defguard_setup/tests/initial_setup.rs | 49 +- ...1511_[2.0.0]_auto-adoption_wizard.down.sql | 13 + ...091511_[2.0.0]_auto-adoption_wizard.up.sql | 18 + web/src/pages/EdgesPage/EdgesTable.tsx | 3 +- .../components/GatewaysTable.tsx | 3 +- web/src/pages/SetupPage/assets/community.png | Bin 0 -> 2088 bytes web/src/pages/SetupPage/assets/shield.png | Bin 0 -> 1769 bytes .../autoAdoption/AutoAdoptionSetupPage.tsx | 260 ++++++ .../steps/AutoAdoptionAdminUserStep.tsx | 281 ++++++ .../steps/AutoAdoptionMfaSetupStep.tsx | 80 ++ .../steps/AutoAdoptionSummaryStep.tsx | 158 ++++ .../steps/AutoAdoptionUrlSettingsStep.tsx | 137 +++ .../steps/AutoAdoptionVpnSettingsStep.tsx | 152 +++ .../SetupPage/autoAdoption/steps/style.scss | 82 ++ .../pages/SetupPage/autoAdoption/style.scss | 93 ++ web/src/pages/SetupPage/autoAdoption/types.ts | 18 + .../useAutoAdoptionSetupWizardStore.tsx | 73 ++ .../SetupPage/{ => initial}/SetupPage.tsx | 22 +- .../CertificateAuthorityInfoCard.tsx | 40 + .../steps/SetupAdminUserStep.tsx | 20 +- .../steps/SetupCertificateAuthorityStep.tsx | 26 +- .../SetupCertificateAuthoritySummaryStep.tsx | 45 +- .../steps/SetupConfirmationStep.tsx | 24 +- .../steps/SetupEdgeAdoptionStep.tsx | 20 +- .../steps/SetupEdgeComponentStep.tsx | 16 +- .../steps/SetupGeneralConfigStep.tsx | 18 +- .../SetupPage/{ => initial}/steps/style.scss | 0 .../pages/SetupPage/{ => initial}/types.ts | 17 + .../{ => initial}/useSetupWizardStore.tsx | 16 +- web/src/routes/_wizard/setup.tsx | 87 +- web/src/shared/api/api.ts | 12 + web/src/shared/api/types.ts | 61 +- .../WizardWelcomePage/WizardWelcomePage.tsx | 28 +- .../wizard/WizardWelcomePage/style.scss | 6 +- web/src/shared/components/wizard/types.ts | 1 + 55 files changed, 3119 insertions(+), 178 deletions(-) create mode 100644 crates/defguard_common/src/db/models/setup_auto_adoption.rs create mode 100644 crates/defguard_setup/src/auto_adoption.rs create mode 100644 crates/defguard_setup/src/handlers/auto_wizard.rs create mode 100644 crates/defguard_setup/src/setup_server.rs create mode 100644 migrations/20260227091511_[2.0.0]_auto-adoption_wizard.down.sql create mode 100644 migrations/20260227091511_[2.0.0]_auto-adoption_wizard.up.sql create mode 100644 web/src/pages/SetupPage/assets/community.png create mode 100644 web/src/pages/SetupPage/assets/shield.png create mode 100644 web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionAdminUserStep.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionMfaSetupStep.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionSummaryStep.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionVpnSettingsStep.tsx create mode 100644 web/src/pages/SetupPage/autoAdoption/steps/style.scss create mode 100644 web/src/pages/SetupPage/autoAdoption/style.scss create mode 100644 web/src/pages/SetupPage/autoAdoption/types.ts create mode 100644 web/src/pages/SetupPage/autoAdoption/useAutoAdoptionSetupWizardStore.tsx rename web/src/pages/SetupPage/{ => initial}/SetupPage.tsx (85%) create mode 100644 web/src/pages/SetupPage/initial/components/CertificateAuthorityInfoCard.tsx rename web/src/pages/SetupPage/{ => initial}/steps/SetupAdminUserStep.tsx (91%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupCertificateAuthorityStep.tsx (89%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupCertificateAuthoritySummaryStep.tsx (67%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupConfirmationStep.tsx (80%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupEdgeAdoptionStep.tsx (87%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupEdgeComponentStep.tsx (83%) rename web/src/pages/SetupPage/{ => initial}/steps/SetupGeneralConfigStep.tsx (89%) rename web/src/pages/SetupPage/{ => initial}/steps/style.scss (100%) rename web/src/pages/SetupPage/{ => initial}/types.ts (55%) rename web/src/pages/SetupPage/{ => initial}/useSetupWizardStore.tsx (83%) diff --git a/Cargo.lock b/Cargo.lock index 6654033d99..8bde578d93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,14 +1608,17 @@ dependencies = [ "defguard_certs", "defguard_common", "defguard_core", + "defguard_proto", "defguard_version", "defguard_web_ui", + "ipnetwork", "reqwest", "semver", "serde", "serde_json", "sqlx", "tokio", + "tonic", "tracing", ] diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 2b397163eb..2c170e4727 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::{auto_adoption::attemp_auto_adoption, setup_server::run_setup_web_server}; use defguard_vpn_stats_purge::run_periodic_stats_purge; use secrecy::ExposeSecret; use tokio::sync::{ @@ -94,7 +94,7 @@ async fn main() -> Result<(), anyhow::Error> { info!("Using HMAC OpenID signing key"); } - let wizard_flags = WizardFlags::init(&pool).await?; + let _wizard_flags = WizardFlags::init(&pool).await?; // initialize default settings Settings::init_defaults(&pool).await?; @@ -102,7 +102,15 @@ async fn main() -> Result<(), anyhow::Error> { initialize_current_settings(&pool).await?; let mut settings = Settings::get_current_settings(); - if wizard_flags.initial_wizard_in_progress && !wizard_flags.initial_wizard_completed { + if !settings.initial_setup_completed { + let starting_with_auto_adoption = + config.adopt_edge.is_some() || config.adopt_gateway.is_some(); + if starting_with_auto_adoption { + if let Err(err) = attemp_auto_adoption(&pool, &config).await { + warn!("Failed to store startup auto-adoption states: {err}"); + } + } + if let Err(err) = run_setup_web_server(pool.clone(), config.http_bind_address, config.http_port).await { diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 6db70311aa..202d70a40a 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -187,6 +187,12 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_GRPC_BIND_ADDRESS")] pub grpc_bind_address: Option, + + #[arg(long, env = "DEFGUARD_ADOPT_GATEWAY")] + pub adopt_gateway: Option, + + #[arg(long, env = "DEFGUARD_ADOPT_EDGE")] + pub adopt_edge: Option, } #[derive(Clone, Debug, Subcommand)] diff --git a/crates/defguard_common/src/db/models/gateway.rs b/crates/defguard_common/src/db/models/gateway.rs index 8de3a5be4a..fad7ba73e6 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -20,7 +20,7 @@ pub struct Gateway { pub certificate_expiry: Option, pub version: Option, pub modified_at: NaiveDateTime, - pub modified_by: Id, + pub modified_by: String, } impl Gateway { @@ -42,7 +42,7 @@ impl Gateway { name: S, address: S, port: i32, - modified_by: Id, + modified_by: S, ) -> Self { // FIXME: this is a workaround for reducing timestamp precision. // `chrono` has nanosecond precision by default, while Postgres only does microseconds. @@ -63,7 +63,7 @@ impl Gateway { certificate: None, certificate_expiry: None, version: None, - modified_by, + modified_by: modified_by.into(), modified_at, } } diff --git a/crates/defguard_common/src/db/models/mod.rs b/crates/defguard_common/src/db/models/mod.rs index e93d57d940..402ba88715 100644 --- a/crates/defguard_common/src/db/models/mod.rs +++ b/crates/defguard_common/src/db/models/mod.rs @@ -14,6 +14,7 @@ pub mod polling_token; pub mod proxy; pub mod session; pub mod settings; +pub mod setup_auto_adoption; pub mod user; pub mod vpn_client_session; pub mod vpn_session_stats; diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index 7685a65389..37e944b102 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -23,7 +23,7 @@ pub struct Proxy { pub certificate: Option, pub certificate_expiry: Option, pub modified_at: NaiveDateTime, - pub modified_by: Id, + pub modified_by: String, } impl fmt::Display for Proxy { @@ -47,7 +47,7 @@ impl Proxy { /// - `port`: TCP port the proxy listens on. /// - `modified_by`: Identifier of the user who created or last modified this proxy. #[must_use] - pub fn new>(name: S, address: S, port: i32, modified_by: Id) -> Self { + pub fn new>(name: S, address: S, port: i32, modified_by: S) -> Self { Self { id: NoId, name: name.into(), @@ -58,7 +58,7 @@ impl Proxy { certificate: None, certificate_expiry: None, version: None, - modified_by, + modified_by: modified_by.into(), modified_at: Utc::now().naive_utc(), } } @@ -81,13 +81,9 @@ impl Proxy { } pub async fn list(pool: &PgPool) -> sqlx::Result> { - sqlx::query_as!( - ProxyInfo, - "SELECT proxy.*, u.first_name modified_by_firstname, u.last_name modified_by_lastname \ - FROM proxy JOIN \"user\" u on proxy.modified_by = u.id", - ) - .fetch_all(pool) - .await + sqlx::query_as!(ProxyInfo, "SELECT * FROM proxy",) + .fetch_all(pool) + .await } pub async fn mark_connected(&mut self, pool: &PgPool, version: &str) -> sqlx::Result<()> { diff --git a/crates/defguard_common/src/db/models/setup_auto_adoption.rs b/crates/defguard_common/src/db/models/setup_auto_adoption.rs new file mode 100644 index 0000000000..cafdb2dd7e --- /dev/null +++ b/crates/defguard_common/src/db/models/setup_auto_adoption.rs @@ -0,0 +1,78 @@ +use std::collections::{BTreeMap, HashMap}; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{PgPool, types::Json}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SetupAutoAdoptionComponent { + Edge, + Gateway, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoAdoptionWizardStep { + #[default] + Welcome, + AdminUser, + UrlSettings, + VpnSettings, + MfaSettings, + Summary, + Finished, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AutoAdoptionComponentResult { + pub success: bool, + pub logs: Vec, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct AutoAdoptionWizardState { + #[serde(default)] + pub step: AutoAdoptionWizardStep, + #[serde(default)] + pub adoption_result: HashMap, +} + +impl AutoAdoptionWizardState { + fn new() -> Self { + Self { + step: AutoAdoptionWizardStep::default(), + adoption_result: HashMap::new(), + } + } + + pub async fn load(pool: &PgPool) -> Result { + let state_json = sqlx::query_scalar::<_, Json>( + "SELECT auto_adoption_wizard_state FROM wizard WHERE is_singleton = TRUE", + ) + .fetch_one(pool) + .await?; + + Ok(state_json.0) + } + + pub async fn save(&self, pool: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE wizard SET auto_adoption_wizard_state = $1 WHERE is_singleton = TRUE") + .bind(Json(self)) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn insert_component_result( + &mut self, + pool: &PgPool, + component: SetupAutoAdoptionComponent, + result: AutoAdoptionComponentResult, + ) -> Result<(), sqlx::Error> { + self.adoption_result.insert(component, result); + self.save(pool).await + } +} diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index 6b5a2cea2b..c128655198 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -1159,6 +1159,10 @@ impl User { .fetch_all(executor) .await } + + pub fn fullname(&self) -> String { + format!("{} {}", self.first_name, self.last_name) + } } impl Distribution> for Standard { diff --git a/crates/defguard_common/src/types/proxy.rs b/crates/defguard_common/src/types/proxy.rs index 0a4d1b5d0a..3e559a7a5b 100644 --- a/crates/defguard_common/src/types/proxy.rs +++ b/crates/defguard_common/src/types/proxy.rs @@ -23,7 +23,5 @@ pub struct ProxyInfo { pub certificate: Option, pub certificate_expiry: Option, pub modified_at: NaiveDateTime, - pub modified_by: Id, - pub modified_by_firstname: String, - pub modified_by_lastname: String, + pub modified_by: String, } diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index c1c46b8f51..5142f3b69b 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -556,7 +556,7 @@ pub async fn setup_proxy_tls_stream( &request.common_name, &request.ip_or_domain, i32::from(request.grpc_port), - session.user.id, + &session.user.fullname(), ); proxy.certificate = Some(serial); @@ -978,7 +978,7 @@ pub async fn setup_gateway_tls_stream( request.common_name, request.ip_or_domain, request.grpc_port.into(), - session.user.id, + session.user.fullname(), ); gateway.certificate = Some(serial); diff --git a/crates/defguard_core/src/handlers/gateway.rs b/crates/defguard_core/src/handlers/gateway.rs index fd3a488dbd..93196a29d8 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -32,9 +32,7 @@ pub struct GatewayInfo { pub certificate_expiry: Option, pub version: Option, pub modified_at: NaiveDateTime, - pub modified_by: Id, - pub modified_by_firstname: String, - pub modified_by_lastname: String, + pub modified_by: String, pub location_name: String, } @@ -43,8 +41,6 @@ impl GatewayInfo { query_as!( Self, "SELECT gateway.*, \ - u.first_name modified_by_firstname, \ - u.last_name modified_by_lastname, \ CASE \ WHEN gateway.connected_at IS NULL THEN false \ WHEN gateway.disconnected_at IS NULL THEN true \ @@ -53,7 +49,6 @@ impl GatewayInfo { END AS \"connected!\", \ wn.name AS location_name \ FROM gateway \ - JOIN \"user\" u on gateway.modified_by = u.id \ JOIN wireguard_network wn ON gateway.location_id = wn.id", ) .fetch_all(pool) @@ -64,8 +59,6 @@ impl GatewayInfo { query_as!( Self, "SELECT gateway.*, \ - u.first_name modified_by_firstname, \ - u.last_name modified_by_lastname, \ CASE \ WHEN gateway.connected_at IS NULL THEN false \ WHEN gateway.disconnected_at IS NULL THEN true \ @@ -73,7 +66,7 @@ impl GatewayInfo { ELSE false \ END AS \"connected!\", \ wn.name AS location_name \ - FROM gateway JOIN \"user\" u on gateway.modified_by = u.id \ + FROM gateway \ JOIN wireguard_network wn ON gateway.location_id = wn.id \ WHERE location_id = $1", location_id diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index 01810f4738..adcc93dc23 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -121,7 +121,7 @@ pub(crate) async fn update_proxy( let before = proxy.clone(); proxy.name = data.name; - proxy.modified_by = session.user.id; + proxy.modified_by = session.user.fullname(); proxy.modified_at = Utc::now().naive_utc(); proxy.save(&appstate.pool).await?; diff --git a/crates/defguard_setup/Cargo.toml b/crates/defguard_setup/Cargo.toml index e68fc756ab..f1768d8ec4 100644 --- a/crates/defguard_setup/Cargo.toml +++ b/crates/defguard_setup/Cargo.toml @@ -12,11 +12,13 @@ defguard_common.workspace = true anyhow.workspace = true axum.workspace = true defguard_web_ui.workspace = true +ipnetwork.workspace = true semver.workspace = true sqlx.workspace = true tokio.workspace = true defguard_core.workspace = true defguard_certs.workspace = true +defguard_proto.workspace = true reqwest.workspace = true serde_json.workspace = true tracing.workspace = true @@ -25,6 +27,7 @@ chrono.workspace = true defguard_version.workspace = true axum-extra.workspace = true axum-client-ip.workspace = true +tonic.workspace = true [dev-dependencies] reqwest = { version = "0.12", features = [ diff --git a/crates/defguard_setup/src/auto_adoption.rs b/crates/defguard_setup/src/auto_adoption.rs new file mode 100644 index 0000000000..24318b37db --- /dev/null +++ b/crates/defguard_setup/src/auto_adoption.rs @@ -0,0 +1,883 @@ +use std::time::Duration; + +use anyhow::Context; +use defguard_certs::{ + CertificateAuthority, CertificateInfo, Csr, PemLabel, der_to_pem, parse_certificate_info, +}; +use defguard_common::{ + VERSION, + auth::claims::{Claims, ClaimsType}, + config::DefGuardConfig, + db::models::{ + Settings, User, WireguardNetwork, + gateway::Gateway, + proxy::Proxy, + settings::update_current_settings, + setup_auto_adoption::{ + AutoAdoptionComponentResult, AutoAdoptionWizardState, SetupAutoAdoptionComponent, + }, + wireguard::{ + DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_WIREGUARD_MTU, + LocationMfaMode, ServiceLocationMode, + }, + }, +}; +use defguard_core::version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}; +use defguard_proto::{ + gateway::{ + CertificateInfo as GatewayCertificateInfo, DerPayload as GatewayDerPayload, + gateway_setup_client::GatewaySetupClient, + }, + proxy::{ + CertificateInfo as ProxyCertificateInfo, DerPayload as ProxyDerPayload, + proxy_setup_client::ProxySetupClient, + }, +}; +use defguard_version::{Version, client::ClientVersionInterceptor}; +use ipnetwork::IpNetwork; +use reqwest::Url; +use sqlx::PgPool; +use tokio::sync::mpsc::UnboundedReceiver; +use tonic::{ + Request, Status, + service::Interceptor, + transport::{Certificate, ClientTlsConfig, Endpoint}, +}; +use tracing::{debug, info, warn}; + +const TOKEN_CLIENT_ID: &str = "Defguard Core"; +const STARTUP_ADOPTION_TIMEOUT: Duration = Duration::from_secs(10); +const AUTO_ADOPTION_CA_COMMON_NAME: &str = "Defguard Automatic Setup CA"; +const AUTO_ADOPTION_CA_EMAIL: &str = "auto-adoption@defguard.local"; +const AUTO_ADOPTION_CA_VALIDITY_DAYS: u32 = 3650; + +async fn ensure_ca_for_auto_adoption(pool: &PgPool) -> Result<(), anyhow::Error> { + let mut settings = Settings::get_current_settings(); + let has_cert = settings.ca_cert_der.is_some(); + let has_key = settings.ca_key_der.is_some(); + + if has_cert && has_key { + debug!("Auto-adoption mode: existing CA certificate/key found"); + return Ok(()); + } + + if has_cert && !has_key { + warn!( + "Auto-adoption mode requested but existing CA has no private key; generating new CA so startup adoption can proceed" + ); + } else { + info!("Auto-adoption mode requested with no CA configured; generating CA automatically"); + } + + let ca = CertificateAuthority::new( + AUTO_ADOPTION_CA_COMMON_NAME, + AUTO_ADOPTION_CA_EMAIL, + AUTO_ADOPTION_CA_VALIDITY_DAYS, + ) + .context("Failed to create automatic setup CA")?; + + settings.ca_cert_der = Some(ca.cert_der().to_vec()); + settings.ca_key_der = Some(ca.key_pair_der().to_vec()); + settings.ca_expiry = Some( + ca.expiry() + .context("Failed to determine automatic CA expiry")?, + ); + + update_current_settings(pool, settings) + .await + .context("Failed to persist automatically generated CA for auto-adoption")?; + + info!( + "Automatic setup CA generated successfully for startup adoption mode (validity_days={AUTO_ADOPTION_CA_VALIDITY_DAYS})" + ); + Ok(()) +} + +fn parse_host_port(input: &str) -> Result<(String, u16), anyhow::Error> { + if let Some(rest) = input.strip_prefix('[') { + let (host, port_part) = rest + .split_once(']') + .context("Invalid endpoint format. Expected [ipv6]:port")?; + let port = port_part + .strip_prefix(':') + .context("Invalid endpoint format. Missing port separator ':'")? + .parse::() + .context("Invalid port in endpoint")?; + return Ok((host.to_string(), port)); + } + + let (host, port) = input + .rsplit_once(':') + .context("Invalid endpoint format. Expected host:port")?; + if host.trim().is_empty() { + anyhow::bail!("Invalid endpoint format. Host cannot be empty"); + } + + Ok(( + host.to_string(), + port.parse::().context("Invalid port in endpoint")?, + )) +} + +fn generated_name(prefix: &str, host: &str, port: u16) -> String { + let safe_host = host + .chars() + .map(|ch| match ch { + '.' | ':' | '[' | ']' => '-', + _ if ch.is_ascii_alphanumeric() || ch == '-' => ch, + _ => '-', + }) + .collect::() + .trim_matches('-') + .to_string(); + format!("{prefix}-{safe_host}-{port}") +} + +#[derive(Clone)] +struct AuthInterceptor { + token: String, +} + +impl AuthInterceptor { + const fn new(token: String) -> Self { + Self { token } + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + request.metadata_mut().insert( + "authorization", + format!("Bearer {}", self.token) + .parse() + .expect("failed to parse auth metadata"), + ); + Ok(request) + } +} + +struct TaskGuard(tokio::task::JoinHandle<()>); + +impl Drop for TaskGuard { + fn drop(&mut self) { + self.0.abort(); + } +} + +fn adoption_failure(message: impl Into) -> (bool, Vec, Option) { + let msg = message.into(); + (false, vec![msg], None) +} + +fn format_component_log(timestamp: &str, level: &str, target: &str, message: &str) -> String { + let level = level + .strip_prefix("Level(") + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(level) + .to_uppercase(); + + format!("{timestamp} {level} {target}: message={message}") +} + +fn collect_stream_logs(log_rx: &mut UnboundedReceiver) -> Vec { + let mut logs = Vec::new(); + while let Ok(log) = log_rx.try_recv() { + logs.push(log); + } + logs +} + +fn adoption_failure_with_logs( + log_rx: &mut UnboundedReceiver, +) -> (bool, Vec, Option) { + let logs = collect_stream_logs(log_rx); + (false, logs, None) +} + +async fn run_edge_adoption_attempt( + _pool: &PgPool, + host: &str, + port: u16, + common_name: &str, +) -> (bool, Vec, Option) { + let (log_tx, mut log_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let settings = Settings::get_current_settings(); + let Some(ca_cert_der) = settings.ca_cert_der else { + return adoption_failure("CA certificate not found in settings"); + }; + let Some(ca_key_der) = settings.ca_key_der else { + return adoption_failure( + "CA private key not found in settings. Uploading CA cert without key cannot auto-adopt.", + ); + }; + let endpoint_str = format!("http://{host}:{port}"); + let url = match Url::parse(&endpoint_str) { + Ok(url) => url, + Err(err) => return adoption_failure(format!("Invalid edge endpoint URL: {err}")), + }; + + let cert_pem = match der_to_pem(&ca_cert_der, PemLabel::Certificate) { + Ok(pem) => pem, + Err(err) => { + return adoption_failure(format!("Failed to convert CA certificate to PEM: {err}")); + } + }; + + let base_endpoint = match Endpoint::from_shared(endpoint_str.clone()) { + Ok(endpoint) => endpoint, + Err(err) => return adoption_failure(format!("Failed to build edge endpoint: {err}")), + }; + + let base_endpoint = base_endpoint + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Some(Duration::from_secs(5))) + .keep_alive_while_idle(true); + + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)); + let endpoint = match base_endpoint.tls_config(tls) { + Ok(endpoint) => endpoint, + Err(err) => { + return adoption_failure(format!("Failed to configure TLS for edge endpoint: {err}")); + } + }; + + let core_version = match Version::parse(VERSION) { + Ok(version) => version, + Err(err) => return adoption_failure(format!("Failed to parse core version: {err}")), + }; + + let token = match Claims::new( + ClaimsType::Gateway, + url.to_string(), + TOKEN_CLIENT_ID.to_string(), + u32::MAX.into(), + ) + .to_jwt() + { + Ok(token) => token, + Err(err) => return adoption_failure(format!("Failed to generate setup token: {err}")), + }; + + let version_interceptor = ClientVersionInterceptor::new(core_version.clone()); + let auth_interceptor = AuthInterceptor::new(token); + + let mut client = + ProxySetupClient::with_interceptor(endpoint.connect_lazy(), move |mut req: Request<()>| { + req = version_interceptor.clone().call(req)?; + auth_interceptor.clone().call(req) + }); + + let response_with_metadata = + match tokio::time::timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { + Ok(Ok(response)) => response, + Ok(Err(err)) => { + return adoption_failure(format!("Failed to start edge setup stream: {err}")); + } + Err(_) => { + return adoption_failure(format!( + "Timed out connecting to edge setup endpoint after {} seconds", + STARTUP_ADOPTION_TIMEOUT.as_secs() + )); + } + }; + + let edge_version = response_with_metadata + .metadata() + .get(defguard_version::VERSION_HEADER) + .and_then(|v| v.to_str().ok()) + .map(defguard_version::Version::parse) + .transpose() + .unwrap_or(None); + + if let Some(edge_version) = edge_version { + if edge_version < MIN_PROXY_VERSION { + return adoption_failure_with_logs(&mut log_rx); + } + } else { + return adoption_failure_with_logs(&mut log_rx); + } + + let mut response = response_with_metadata.into_inner(); + let log_reader_task = tokio::spawn(async move { + loop { + match response.message().await { + Ok(Some(entry)) => { + let formatted = format_component_log( + &entry.timestamp, + &entry.level, + &entry.target, + &entry.message, + ); + if log_tx.send(formatted).is_err() { + break; + } + } + Ok(None) => break, + Err(err) => { + let _ = log_tx.send(format!("Error reading log: {err}")); + break; + } + } + } + }); + let _log_task_guard = TaskGuard(log_reader_task); + + let Some(hostname) = url.host_str() else { + return adoption_failure_with_logs(&mut log_rx); + }; + + let csr_response = match client + .get_csr(ProxyCertificateInfo { + cert_hostname: hostname.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let csr = match Csr::from_der(&csr_response.der_data) { + Ok(csr) => csr, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let ca = match CertificateAuthority::from_cert_der_key_pair(&ca_cert_der, &ca_key_der) { + Ok(ca) => ca, + Err(err) => { + return adoption_failure(format!("Failed to build certificate authority: {err}")); + } + }; + + let cert = match ca.sign_csr(&csr) { + Ok(cert) => cert, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + if let Err(err) = client + .send_cert(ProxyDerPayload { + der_data: cert.der().to_vec(), + }) + .await + { + return adoption_failure_with_logs(&mut log_rx); + } + + let cert_info = match parse_certificate_info(cert.der()) { + Ok(info) => info, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let mut logs = collect_stream_logs(&mut log_rx); + if logs.is_empty() { + logs = vec!["No runtime logs received from edge component".to_string()]; + } + + (true, logs, Some(cert_info)) +} + +async fn run_gateway_adoption_attempt( + host: &str, + port: u16, + common_name: &str, +) -> (bool, Vec, Option) { + let (log_tx, mut log_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let settings = Settings::get_current_settings(); + let Some(ca_cert_der) = settings.ca_cert_der else { + return adoption_failure("CA certificate not found in settings"); + }; + let Some(ca_key_der) = settings.ca_key_der else { + return adoption_failure( + "CA private key not found in settings. Uploading CA cert without key cannot auto-adopt.", + ); + }; + + let endpoint_str = format!("http://{host}:{port}"); + let url = match Url::parse(&endpoint_str) { + Ok(url) => url, + Err(err) => return adoption_failure(format!("Invalid gateway endpoint URL: {err}")), + }; + + let cert_pem = match der_to_pem(&ca_cert_der, PemLabel::Certificate) { + Ok(pem) => pem, + Err(err) => { + return adoption_failure(format!("Failed to convert CA certificate to PEM: {err}")); + } + }; + + let base_endpoint = match Endpoint::from_shared(endpoint_str.clone()) { + Ok(endpoint) => endpoint, + Err(err) => return adoption_failure(format!("Failed to build gateway endpoint: {err}")), + }; + + let base_endpoint = base_endpoint + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Some(Duration::from_secs(5))) + .keep_alive_while_idle(true); + + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)); + let endpoint = match base_endpoint.tls_config(tls) { + Ok(endpoint) => endpoint, + Err(err) => { + return adoption_failure(format!( + "Failed to configure TLS for gateway endpoint: {err}" + )); + } + }; + + let core_version = match Version::parse(VERSION) { + Ok(version) => version, + Err(err) => return adoption_failure(format!("Failed to parse core version: {err}")), + }; + + let token = match Claims::new( + ClaimsType::Gateway, + url.to_string(), + TOKEN_CLIENT_ID.to_string(), + u32::MAX.into(), + ) + .to_jwt() + { + Ok(token) => token, + Err(err) => return adoption_failure(format!("Failed to generate setup token: {err}")), + }; + + let version_interceptor = ClientVersionInterceptor::new(core_version.clone()); + let auth_interceptor = AuthInterceptor::new(token); + + let mut client = GatewaySetupClient::with_interceptor( + endpoint.connect_lazy(), + move |mut req: Request<()>| { + req = version_interceptor.clone().call(req)?; + auth_interceptor.clone().call(req) + }, + ); + + let response_with_metadata = + match tokio::time::timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { + Ok(Ok(response)) => response, + Ok(Err(err)) => { + return adoption_failure(format!("Failed to start gateway setup stream: {err}")); + } + Err(_) => { + return adoption_failure(format!( + "Timed out connecting to gateway setup endpoint after {} seconds", + STARTUP_ADOPTION_TIMEOUT.as_secs() + )); + } + }; + + let gateway_version = response_with_metadata + .metadata() + .get(defguard_version::VERSION_HEADER) + .and_then(|v| v.to_str().ok()) + .map(defguard_version::Version::parse) + .transpose() + .unwrap_or(None); + + if let Some(gateway_version) = gateway_version { + if gateway_version < MIN_GATEWAY_VERSION { + return adoption_failure_with_logs(&mut log_rx); + } + } else { + return adoption_failure_with_logs(&mut log_rx); + } + + let mut response = response_with_metadata.into_inner(); + let log_reader_task = tokio::spawn(async move { + loop { + match response.message().await { + Ok(Some(entry)) => { + let formatted = format_component_log( + &entry.timestamp, + &entry.level, + &entry.target, + &entry.message, + ); + if log_tx.send(formatted).is_err() { + break; + } + } + Ok(None) => break, + Err(err) => { + let _ = log_tx.send(format!("Error reading log: {err}")); + break; + } + } + } + }); + let _log_task_guard = TaskGuard(log_reader_task); + + let Some(hostname) = url.host_str() else { + return adoption_failure_with_logs(&mut log_rx); + }; + + let csr_response = match client + .get_csr(GatewayCertificateInfo { + cert_hostname: hostname.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let csr = match Csr::from_der(&csr_response.der_data) { + Ok(csr) => csr, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let ca = match CertificateAuthority::from_cert_der_key_pair(&ca_cert_der, &ca_key_der) { + Ok(ca) => ca, + Err(err) => { + return adoption_failure(format!("Failed to build certificate authority: {err}")); + } + }; + + let cert = match ca.sign_csr(&csr) { + Ok(cert) => cert, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + if let Err(err) = client + .send_cert(GatewayDerPayload { + der_data: cert.der().to_vec(), + }) + .await + { + return adoption_failure_with_logs(&mut log_rx); + } + + let cert_info = match parse_certificate_info(cert.der()) { + Ok(info) => info, + Err(err) => { + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let mut logs = collect_stream_logs(&mut log_rx); + if logs.is_empty() { + logs = vec!["No runtime logs received from gateway component".to_string()]; + } + + (true, logs, Some(cert_info)) +} + +// Default WireGuard network address and port used when auto-adopting a gateway without an +// existing network. The gateway's own gRPC host is reused as the WireGuard endpoint so peers +// can reach it. +const DEFAULT_AUTO_ADOPTION_NETWORK_ADDRESS: &str = "10.0.0.1/24"; +const DEFAULT_AUTO_ADOPTION_WIREGUARD_PORT: i32 = 51820; + +async fn process_startup_auto_adoption( + pool: &PgPool, + component: SetupAutoAdoptionComponent, + endpoint: &str, + prefix: &str, +) -> Result<(), anyhow::Error> { + let (host, port) = parse_host_port(endpoint)?; + let common_name = generated_name(prefix, &host, port); + + let (status, logs, cert_info) = match component { + SetupAutoAdoptionComponent::Edge => { + run_edge_adoption_attempt(pool, &host, port, &common_name).await + } + SetupAutoAdoptionComponent::Gateway => { + run_gateway_adoption_attempt(&host, port, &common_name).await + } + }; + + // On successful adoption: create the relevant DB records. + if status { + match component { + SetupAutoAdoptionComponent::Gateway => { + if let Some(cert_info) = cert_info { + if let Err(err) = + create_network_and_gateway(pool, &host, port, &common_name, cert_info).await + { + warn!( + "Gateway adoption TLS handshake succeeded but failed to persist \ + network/gateway records: {err}" + ); + } + } + } + SetupAutoAdoptionComponent::Edge => { + if let Some(cert_info) = cert_info { + if let Err(err) = create_proxy(pool, &host, port, &common_name, cert_info).await + { + warn!( + "Edge adoption TLS handshake succeeded but failed to persist \ + proxy record: {err}" + ); + } + } + } + } + } + + let mut wizard_state = AutoAdoptionWizardState::load(pool) + .await + .context("Failed to load auto-adoption wizard state")?; + + wizard_state + .insert_component_result( + pool, + component, + AutoAdoptionComponentResult { + success: status, + logs: logs.clone(), + updated_at: chrono::Utc::now().naive_utc(), + }, + ) + .await?; + + Ok(()) +} + +/// Creates a [`WireguardNetwork`] (location) pre-filled with auto-generated defaults and then +/// creates the associated [`Gateway`] record with the certificate data obtained during adoption. +async fn create_network_and_gateway( + pool: &PgPool, + host: &str, + grpc_port: u16, + common_name: &str, + cert_info: CertificateInfo, +) -> Result<(), anyhow::Error> { + // Re-use or create the network location. + let network = if let Some(existing) = WireguardNetwork::find_by_name(pool, common_name) + .await + .context("Failed to query network by name")? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) { + info!( + "Auto-adoption: reusing existing network location name={common_name} \ +id={} for new gateway", + existing.id + ); + existing + } else { + let network_address: IpNetwork = DEFAULT_AUTO_ADOPTION_NETWORK_ADDRESS + .parse() + .context("Failed to parse default auto-adoption network address")?; + + let mut transaction = pool.begin().await.context("Failed to begin transaction")?; + let network = WireguardNetwork::new( + common_name.to_string(), + vec![network_address], + DEFAULT_AUTO_ADOPTION_WIREGUARD_PORT, + host.to_string(), + None, + DEFAULT_WIREGUARD_MTU, + 0, + vec![], + DEFAULT_KEEPALIVE_INTERVAL, + DEFAULT_DISCONNECT_THRESHOLD, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .save(&mut *transaction) + .await + .context("Failed to save auto-adopted WireguardNetwork")?; + + network + .add_all_allowed_devices(&mut transaction) + .await + .context("Failed to assign IPs for existing devices in auto-adopted network")?; + + transaction + .commit() + .await + .context("Failed to commit auto-adoption network transaction")?; + + info!( + "Auto-adoption: created network location name={common_name} id={}", + network.id + ); + network + }; + + // Avoid duplicate gateway records for the same address:port. + if let Some(existing) = Gateway::find_by_url(pool, host, grpc_port) + .await + .context("Failed to query existing gateways")? + { + info!( + "Auto-adoption: gateway already registered at {host}:{grpc_port} (id={}); \ + skipping gateway record creation", + existing.id + ); + return Ok(()); + } + + let mut gateway = Gateway::new( + network.id, + common_name, + host, + i32::from(grpc_port), + "Automatic setup", + ); + gateway.certificate = Some(cert_info.serial); + gateway.certificate_expiry = Some(cert_info.not_after); + + gateway + .save(pool) + .await + .context("Failed to save auto-adopted Gateway")?; + + info!( + "Auto-adoption: created gateway record name={common_name} address={host}:{grpc_port} \ + network_id={}", + network.id + ); + + Ok(()) +} + +/// Creates a [`Proxy`] record in the database after a successful edge adoption. +async fn create_proxy( + pool: &PgPool, + host: &str, + port: u16, + common_name: &str, + cert_info: CertificateInfo, +) -> Result<(), anyhow::Error> { + if let Some(existing) = Proxy::find_by_address_port(pool, host, i32::from(port)) + .await + .context("Failed to query existing proxies")? + { + info!( + "Auto-adoption: proxy already registered at {host}:{port} (id={}); \ + skipping proxy record creation", + existing.id + ); + return Ok(()); + } + + let mut proxy = Proxy::new(common_name, host, i32::from(port), "Automatic setup"); + proxy.certificate = Some(cert_info.serial); + proxy.certificate_expiry = Some(cert_info.not_after); + + proxy + .save(pool) + .await + .context("Failed to save auto-adopted Proxy")?; + + info!("Auto-adoption: created proxy record name={common_name} address={host}:{port}"); + + Ok(()) +} + +/// Stores and updates startup auto-adoption states for components requested via CLI flags. +pub async fn attemp_auto_adoption( + pool: &PgPool, + config: &DefGuardConfig, +) -> Result<(), anyhow::Error> { + let mut wizard_state = AutoAdoptionWizardState::load(pool) + .await + .context("Failed to load existing setup auto-adoption states")?; + + let edge_already_succeeded = wizard_state + .adoption_result + .get(&SetupAutoAdoptionComponent::Edge) + .map_or(false, |result| result.success); + let gateway_already_succeeded = wizard_state + .adoption_result + .get(&SetupAutoAdoptionComponent::Gateway) + .map_or(false, |result| result.success); + + let should_run_edge = config.adopt_edge.is_some() && !edge_already_succeeded; + let should_run_gateway = config.adopt_gateway.is_some() && !gateway_already_succeeded; + let auto_mode_requested = should_run_edge || should_run_gateway; + if auto_mode_requested { + ensure_ca_for_auto_adoption(pool).await?; + } + + if let Some(endpoint) = &config.adopt_edge { + if edge_already_succeeded { + info!( + "Skipping startup auto-adoption for Edge component endpoint={endpoint} as it was already completed" + ); + } else { + info!("Starting startup auto-adoption for Edge component endpoint={endpoint}"); + if let Err(err) = process_startup_auto_adoption( + pool, + SetupAutoAdoptionComponent::Edge, + endpoint, + "edge", + ) + .await + { + wizard_state + .insert_component_result( + pool, + SetupAutoAdoptionComponent::Edge, + AutoAdoptionComponentResult { + success: false, + logs: vec![format!("Startup auto-adoption failed: {err}")], + updated_at: chrono::Utc::now().naive_utc(), + }, + ) + .await?; + } else { + info!("Startup auto-adoption for Edge component completed endpoint={endpoint}"); + } + } + } + + if let Some(endpoint) = &config.adopt_gateway { + if gateway_already_succeeded { + info!( + "Skipping startup auto-adoption for Gateway component endpoint={endpoint} as it was already completed" + ); + } else { + info!("Starting startup auto-adoption for Gateway component endpoint={endpoint}"); + if let Err(err) = process_startup_auto_adoption( + pool, + SetupAutoAdoptionComponent::Gateway, + endpoint, + "gateway", + ) + .await + { + wizard_state + .insert_component_result( + pool, + SetupAutoAdoptionComponent::Gateway, + AutoAdoptionComponentResult { + success: false, + logs: vec![format!("Startup auto-adoption failed: {err}")], + updated_at: chrono::Utc::now().naive_utc(), + }, + ) + .await?; + } else { + info!("Startup auto-adoption for Gateway component completed endpoint={endpoint}"); + } + } + } + + Ok(()) +} diff --git a/crates/defguard_setup/src/handlers/auto_wizard.rs b/crates/defguard_setup/src/handlers/auto_wizard.rs new file mode 100644 index 0000000000..2ea90ab77b --- /dev/null +++ b/crates/defguard_setup/src/handlers/auto_wizard.rs @@ -0,0 +1,205 @@ +use axum::{Extension, Json}; +use defguard_common::{ + db::models::{ + WireguardNetwork, + settings::update_current_settings, + setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, + wireguard::LocationMfaMode, + }, + utils::{parse_address_list, parse_network_address_list}, +}; +use defguard_core::{ + auth::AdminOrSetupRole, + error::WebError, + handlers::{ApiResponse, ApiResult}, +}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::{PgPool, query_scalar}; +use tracing::{debug, info}; + +pub(crate) async fn is_auto_wizard_active(pool: &PgPool) -> Result { + let state = AutoAdoptionWizardState::load(pool).await?; + Ok(!state.adoption_result.is_empty()) +} + +pub(crate) async fn advance_auto_wizard_to_step( + pool: &PgPool, + step: AutoAdoptionWizardStep, +) -> Result<(), WebError> { + let mut state = AutoAdoptionWizardState::load(pool).await?; + if state.step < step { + state.step = step; + state.save(pool).await?; + info!("Advanced auto wizard setup to step {:?}", step); + } else { + debug!( + "Not advancing auto wizard setup step from {:?} to {:?} as it is not a forward step", + state.step, step + ); + } + + Ok(()) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct UrlSettingsConfig { + defguard_url: String, + public_proxy_url: String, +} + +/// Updates URL settings used by auto-adoption wizard. +pub async fn set_url_settings( + _: AdminOrSetupRole, + Extension(pool): Extension, + Json(url_settings): Json, +) -> ApiResult { + info!("Applying Auto-adoption wizard URL settings"); + debug!( + "URL settings received: defguard_url={}, public_proxy_url={}", + url_settings.defguard_url, url_settings.public_proxy_url, + ); + + let mut settings = defguard_common::db::models::Settings::get_current_settings(); + settings.defguard_url = url_settings.defguard_url; + settings.public_proxy_url = url_settings.public_proxy_url; + update_current_settings(&pool, settings).await?; + + advance_auto_wizard_to_step(&pool, AutoAdoptionWizardStep::VpnSettings).await?; + + info!("Auto-adoption wizard URL settings applied"); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +#[allow(clippy::struct_field_names)] +#[derive(Deserialize, Serialize, Debug)] +pub struct VpnSettingsConfig { + #[serde(rename = "vpn_public_ip")] + public_ip: String, + #[serde(rename = "vpn_wireguard_port")] + wireguard_port: i32, + #[serde(rename = "vpn_gateway_address")] + gateway_address: String, + #[serde(rename = "vpn_allowed_ips")] + allowed_ips: String, + #[serde(rename = "vpn_dns_server_ip")] + dns_server_ip: String, +} + +/// Updates first auto-adopted network location with VPN settings from auto-adoption wizard. +pub async fn set_vpn_settings( + _: AdminOrSetupRole, + Extension(pool): Extension, + Json(vpn_settings): Json, +) -> ApiResult { + info!("Applying Auto-adoption wizard VPN settings"); + + let first_network_id = + query_scalar::<_, i64>("SELECT id FROM wireguard_network ORDER BY id ASC LIMIT 1") + .fetch_optional(&pool) + .await? + .ok_or_else(|| { + WebError::ObjectNotFound("No network location found to configure".to_string()) + })?; + + let mut network = WireguardNetwork::find_by_id(&pool, first_network_id) + .await? + .ok_or_else(|| { + WebError::ObjectNotFound(format!( + "Network location with ID '{first_network_id}' not found" + )) + })?; + + let addresses = parse_address_list(vpn_settings.gateway_address.as_str()); + if addresses.is_empty() { + return Err(WebError::BadRequest( + "Invalid gateway address value".to_string(), + )); + } + + let allowed_ips_input = vpn_settings.allowed_ips.trim(); + let allowed_ips = if allowed_ips_input.is_empty() { + Vec::new() + } else { + let parsed = parse_network_address_list(allowed_ips_input); + if parsed.is_empty() { + return Err(WebError::BadRequest( + "Invalid allowed IPs value".to_string(), + )); + } + parsed + }; + + network.endpoint = vpn_settings.public_ip; + network.port = vpn_settings.wireguard_port; + network.address = addresses; + network.allowed_ips = allowed_ips; + network.dns = { + let dns = vpn_settings.dns_server_ip.trim(); + if dns.is_empty() { + None + } else { + Some(dns.to_string()) + } + }; + network.save(&pool).await?; + + advance_auto_wizard_to_step(&pool, AutoAdoptionWizardStep::MfaSettings).await?; + + debug!( + "Auto-adoption VPN settings applied to network_id={} endpoint={} port={}", + network.id, network.endpoint, network.port + ); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct MfaSettingsConfig { + #[serde(rename = "vpn_mfa_mode")] + mfa_mode: LocationMfaMode, +} + +/// Updates first auto-adopted network location with MFA mode from Auto-adoption wizard. +pub async fn set_mfa_settings( + _: AdminOrSetupRole, + Extension(pool): Extension, + Json(mfa_settings): Json, +) -> ApiResult { + info!("Applying Auto-adoption wizard MFA settings"); + + let first_network_id = + query_scalar::<_, i64>("SELECT id FROM wireguard_network ORDER BY id ASC LIMIT 1") + .fetch_optional(&pool) + .await? + .ok_or_else(|| { + WebError::ObjectNotFound("No network location found to configure".to_string()) + })?; + + let mut network = WireguardNetwork::find_by_id(&pool, first_network_id) + .await? + .ok_or_else(|| { + WebError::ObjectNotFound(format!( + "Network location with ID '{first_network_id}' not found" + )) + })?; + + network.location_mfa_mode = mfa_settings.mfa_mode; + network.save(&pool).await?; + + advance_auto_wizard_to_step(&pool, AutoAdoptionWizardStep::Summary).await?; + + debug!( + "Auto-adoption MFA settings applied to network_id={} location_mfa_mode={:?}", + network.id, network.location_mfa_mode + ); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +pub async fn get_auto_adoption_result(Extension(pool): Extension) -> ApiResult { + let state = AutoAdoptionWizardState::load(&pool).await?; + Ok(ApiResponse::new(json!(state), StatusCode::OK)) +} diff --git a/crates/defguard_setup/src/handlers/initial_wizard.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs index 4b7b952dc9..25ef138562 100644 --- a/crates/defguard_setup/src/handlers/initial_wizard.rs +++ b/crates/defguard_setup/src/handlers/initial_wizard.rs @@ -15,6 +15,7 @@ use defguard_common::db::models::{ Session, SessionState, Settings, User, group::Group, settings::{InitialSetupStep, update_current_settings}, + setup_auto_adoption::AutoAdoptionWizardStep, }; use defguard_core::{ auth::{ @@ -32,6 +33,8 @@ use sqlx::PgPool; use tokio::sync::oneshot; use tracing::{debug, info}; +use crate::handlers::auto_wizard::{advance_auto_wizard_to_step, is_auto_wizard_active}; + async fn advance_setup_to_step(pool: &PgPool, step: InitialSetupStep) -> Result<(), WebError> { let mut settings = Settings::get_current_settings(); @@ -54,6 +57,26 @@ async fn advance_setup_to_step(pool: &PgPool, step: InitialSetupStep) -> Result< Ok(()) } +async fn advance_wizard_to_step( + pool: &PgPool, + initial_step: Option, + auto_step: Option, +) -> Result<(), WebError> { + if let Some(step) = auto_step + && is_auto_wizard_active(pool).await? + { + advance_auto_wizard_to_step(pool, step).await?; + } + + if let Some(initial_step) = initial_step + && !is_auto_wizard_active(pool).await? + { + advance_setup_to_step(pool, initial_step).await?; + } + + Ok(()) +} + #[derive(Deserialize, Serialize, Debug)] pub struct CreateAdmin { first_name: String, @@ -61,6 +84,8 @@ pub struct CreateAdmin { username: String, email: String, password: String, + #[serde(default)] + automatically_assign_group: bool, } #[derive(Deserialize, Serialize, Debug)] @@ -76,7 +101,12 @@ pub async fn create_admin( Extension(pool): Extension, Json(admin): Json, ) -> Result<(CookieJar, ApiResponse), WebError> { - advance_setup_to_step(&pool, InitialSetupStep::AdminUser).await?; + advance_wizard_to_step( + &pool, + Some(InitialSetupStep::AdminUser), + Some(AutoAdoptionWizardStep::AdminUser), + ) + .await?; info!( "Creating initial admin user {} ({})", admin.username, admin.email @@ -98,6 +128,29 @@ pub async fn create_admin( update_current_settings(&pool, settings).await?; debug!("Initial admin user set as default admin in settings"); + if admin.automatically_assign_group { + let settings = Settings::get_current_settings(); + let default_admin_group_name = settings.default_admin_group_name; + + let admin_group = + if let Some(mut group) = Group::find_by_name(&pool, &default_admin_group_name).await? { + group.is_admin = true; + group.save(&pool).await?; + group + } else { + let mut group = Group::new(&default_admin_group_name); + group.is_admin = true; + group.save(&pool).await? + }; + + user.add_to_group(&pool, &admin_group).await?; + + debug!( + "Automatically assigned admin user {} to admin group {}", + user.username, admin_group.name + ); + } + let device_info = get_device_info(user_agent.as_str()); Session::delete_expired(&pool).await?; @@ -117,7 +170,12 @@ pub async fn create_admin( info!("Initial admin user created"); - advance_setup_to_step(&pool, InitialSetupStep::GeneralConfiguration).await?; + advance_wizard_to_step( + &pool, + Some(InitialSetupStep::GeneralConfiguration), + Some(AutoAdoptionWizardStep::UrlSettings), + ) + .await?; Ok((cookies, ApiResponse::with_status(StatusCode::CREATED))) } diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs index efd8e82968..71e7315709 100644 --- a/crates/defguard_setup/src/handlers/mod.rs +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -1 +1,2 @@ +pub mod auto_wizard; pub mod initial_wizard; diff --git a/crates/defguard_setup/src/lib.rs b/crates/defguard_setup/src/lib.rs index 5d1cc01be6..42994a2c55 100644 --- a/crates/defguard_setup/src/lib.rs +++ b/crates/defguard_setup/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auto_adoption; pub mod db; pub mod handlers; -pub mod setup; +pub mod setup_server; diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs index 9146111267..bb788e144a 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup.rs @@ -22,9 +22,12 @@ use sqlx::PgPool; use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; -use crate::handlers::initial_wizard::{ - create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, setup_session, - upload_ca, +use crate::handlers::{ + auto_wizard::{set_mfa_settings, set_url_settings, set_vpn_settings}, + initial_wizard::{ + create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, + setup_session, upload_ca, + }, }; pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { @@ -50,6 +53,9 @@ pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sen .route("/admin", post(create_admin)) .route("/login", post(setup_login)) .route("/session", get(setup_session)) + .route("/auto_wizard/url_settings", post(set_url_settings)) + .route("/auto_wizard/vpn_settings", post(set_vpn_settings)) + .route("/auto_wizard/mfa_settings", post(set_mfa_settings)) .route("/finish", post(finish_setup)), ), ) diff --git a/crates/defguard_setup/src/setup_server.rs b/crates/defguard_setup/src/setup_server.rs new file mode 100644 index 0000000000..022122c382 --- /dev/null +++ b/crates/defguard_setup/src/setup_server.rs @@ -0,0 +1,100 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::{Arc, Mutex}, +}; + +use anyhow::anyhow; +use axum::{ + Extension, Router, + routing::{get, post}, + serve, +}; +use defguard_common::VERSION; +use defguard_core::{ + auth::failed_login::FailedLoginMap, + handle_404, + handlers::{component_setup::setup_proxy_tls_stream, settings::get_settings_essentials}, + health_check, +}; +use defguard_web_ui::{index, svg, web_asset}; +use semver::Version; +use sqlx::PgPool; +use tokio::{net::TcpListener, sync::oneshot::Sender}; +use tracing::{info, instrument}; + +use crate::handlers::{ + auto_wizard::{get_auto_adoption_result, set_mfa_settings, set_url_settings, set_vpn_settings}, + initial_wizard::{ + create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, + setup_session, upload_ca, + }, +}; + +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() + .route("/", get(index)) + .route("/{*path}", get(index)) + .route("/fonts/{*path}", get(web_asset)) + .route("/assets/{*path}", get(web_asset)) + .route("/svg/{*path}", get(svg)) + .nest( + "/api/v1", + Router::<()>::new() + .route("/health", get(health_check)) + .route("/settings_essentials", get(get_settings_essentials)) + .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) + .nest( + "/initial_setup", + Router::<()>::new() + .route("/ca", post(create_ca).get(get_ca)) + .route("/ca/upload", post(upload_ca)) + .route("/general_config", post(set_general_config)) + .route("/admin", post(create_admin)) + .route("/login", post(setup_login)) + .route("/session", get(setup_session)) + // .route("/step", post(advance_setup_step)) + .route("/auto_adoption", get(get_auto_adoption_result)) + .route("/auto_wizard/url_settings", post(set_url_settings)) + .route("/auto_wizard/vpn_settings", post(set_vpn_settings)) + .route("/auto_wizard/mfa_settings", post(set_mfa_settings)) + .route("/finish", post(finish_setup)), + ), + ) + .fallback_service(get(handle_404)) + .layer(Extension(pool)) + .layer(Extension(version)) + .layer(Extension(failed_logins)) + .layer(Extension(Arc::new(Mutex::new(Some(setup_shutdown_tx))))) +} + +#[instrument(skip_all)] +pub async fn run_setup_web_server( + pool: PgPool, + http_bind_address: Option, + http_port: u16, +) -> Result<(), anyhow::Error> { + let (setup_shutdown_tx, setup_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let setup_webapp = build_setup_webapp( + pool.clone(), + defguard_version::Version::parse(VERSION)?, + setup_shutdown_tx, + ); + + info!("Starting initial setup web server on port {http_port}"); + let addr = SocketAddr::new( + http_bind_address.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + http_port, + ); + let listener = TcpListener::bind(&addr).await?; + serve( + listener, + setup_webapp.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + setup_shutdown_rx.await.ok(); + info!("Shutting down initial setup web server"); + }) + .await + .map_err(|err| anyhow!("Web server can't be started {err}")) +} diff --git a/crates/defguard_setup/tests/initial_setup.rs b/crates/defguard_setup/tests/initial_setup.rs index 7c0ee0c139..6664603295 100644 --- a/crates/defguard_setup/tests/initial_setup.rs +++ b/crates/defguard_setup/tests/initial_setup.rs @@ -16,7 +16,7 @@ use defguard_common::{ setup_pool, }, }; -use defguard_setup::setup::build_setup_webapp; +use defguard_setup::setup_server::build_setup_webapp; use reqwest::{ Client, StatusCode, cookie::Jar, @@ -159,6 +159,53 @@ async fn test_create_admin(_: PgPoolOptions, options: PgConnectOptions) { assert_setup_step(&pool, InitialSetupStep::GeneralConfiguration).await; } +#[sqlx::test] +async fn test_create_admin_with_automatic_group_assignment( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let default_admin_group_name = Settings::get_current_settings().default_admin_group_name; + + let payload = json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!", + "automatically_assign_group": true + }); + + let response = client + .post("/api/v1/initial_setup/admin") + .json(&payload) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + + let group = Group::find_by_name(&pool, &default_admin_group_name) + .await + .expect("Failed to fetch group") + .expect("Default admin group not created"); + assert!(group.is_admin); + + let admin = User::find_by_username(&pool, "admin1") + .await + .expect("Failed to fetch admin") + .expect("Admin user missing"); + let groups = admin + .member_of_names(&pool) + .await + .expect("Failed to fetch group membership"); + assert!(groups.contains(&default_admin_group_name)); +} + #[sqlx::test] async fn test_setup_login_too_many_attempts(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.down.sql b/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.down.sql new file mode 100644 index 0000000000..0fe7413ce7 --- /dev/null +++ b/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.down.sql @@ -0,0 +1,13 @@ +ALTER TABLE wizard DROP COLUMN auto_adoption_wizard_needed, + DROP COLUMN auto_adoption_wizard_state, + DROP COLUMN auto_adoption_wizard_completed, + DROP COLUMN auto_adoption_wizard_in_progress; + +-- NOTE: conversion from name back to id is not possible; existing rows will be set to NULL. +ALTER TABLE proxy + ALTER COLUMN modified_by TYPE bigint USING NULL, + ADD CONSTRAINT proxy_modified_by_fkey FOREIGN KEY (modified_by) REFERENCES "user"(id); + +ALTER TABLE gateway + ALTER COLUMN modified_by TYPE bigint USING NULL, + ADD CONSTRAINT proxy_modified_by_fkey FOREIGN KEY (modified_by) REFERENCES "user"(id); diff --git a/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.up.sql b/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.up.sql new file mode 100644 index 0000000000..c09aba37d7 --- /dev/null +++ b/migrations/20260227091511_[2.0.0]_auto-adoption_wizard.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE wizard ADD COLUMN auto_adoption_wizard_needed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN auto_adoption_wizard_state JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN auto_adoption_wizard_completed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN auto_adoption_wizard_in_progress BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE proxy ADD COLUMN modified_by_name text; +UPDATE proxy SET modified_by_name = u.first_name || ' ' || u.last_name + FROM "user" u WHERE u.id = proxy.modified_by; +ALTER TABLE proxy DROP CONSTRAINT proxy_modified_by_fkey, DROP COLUMN modified_by; +ALTER TABLE proxy RENAME COLUMN modified_by_name TO modified_by; +ALTER TABLE proxy ALTER COLUMN modified_by SET NOT NULL; + +ALTER TABLE gateway ADD COLUMN modified_by_name text; +UPDATE gateway SET modified_by_name = u.first_name || ' ' || u.last_name + FROM "user" u WHERE u.id = gateway.modified_by; +ALTER TABLE gateway DROP CONSTRAINT proxy_modified_by_fkey, DROP COLUMN modified_by; +ALTER TABLE gateway RENAME COLUMN modified_by_name TO modified_by; +ALTER TABLE gateway ALTER COLUMN modified_by SET NOT NULL; diff --git a/web/src/pages/EdgesPage/EdgesTable.tsx b/web/src/pages/EdgesPage/EdgesTable.tsx index 2d9abe5506..69e691a7bf 100644 --- a/web/src/pages/EdgesPage/EdgesTable.tsx +++ b/web/src/pages/EdgesPage/EdgesTable.tsx @@ -43,8 +43,7 @@ const isConnected = (edge: EdgeInfo) => { return connected > disconnected; }; -const displayModifiedBy = (edge: EdgeInfo) => - `${edge.modified_by_firstname} ${edge.modified_by_lastname}`; +const displayModifiedBy = (edge: EdgeInfo) => `${edge.modified_by}`; export const EdgesTable = () => { const { data: edges } = useSuspenseQuery(getEdgesQueryOptions); diff --git a/web/src/pages/LocationsPage/components/GatewaysTable.tsx b/web/src/pages/LocationsPage/components/GatewaysTable.tsx index 3f33bec867..33cfd443c5 100644 --- a/web/src/pages/LocationsPage/components/GatewaysTable.tsx +++ b/web/src/pages/LocationsPage/components/GatewaysTable.tsx @@ -27,8 +27,7 @@ type RowData = GatewayInfo; const columnHelper = createColumnHelper(); -const displayModifiedBy = (gateway: GatewayInfo) => - `${gateway.modified_by_firstname} ${gateway.modified_by_lastname}`; +const displayModifiedBy = (gateway: GatewayInfo) => `${gateway.modified_by}`; const getStatusBadge = (gateway: GatewayInfo) => { if (gateway.connected) { diff --git a/web/src/pages/SetupPage/assets/community.png b/web/src/pages/SetupPage/assets/community.png new file mode 100644 index 0000000000000000000000000000000000000000..2e732679dc3196a43c99ace6eb78fe0fc706f42a GIT binary patch literal 2088 zcmV+@2-o+CP)Zns%{Gf^kL`ba=5h-EDP^G~p zO`r)MVcBfHcHh0X=f1~ocC#DErkt7mcyHgk_xH~Eo!>cU8SbF1Zq#`yXg>j_HZLgT zd4nSgSAu9`mij`=)85Wo-OJ(+9(zoCx--Es1sn3(_d}{ZOBeEz@6-z9co`i6$J!M3 zy1Td!oVl7=5U?1omqke1qQezz?xy&0#jwKRU(W(LG?c-eHzW+ILK;SrW! zhxOKJS!`27jX-Tx>f050rS)xv-yBuw>{HIU&E*mFp>E6c7n$6hu%$f`CE&Qcj*lL5fB zRml{#(t?!@)jS~#@fI4^cylGspf!Iw?vhJdOiJmoz( zSwP1KCJBT<bY{T$nfjJ^GE++O~v^ra{X zh{FfX_BVai<6ltZ?mRRKjF9vgK$#vl4BKCk&J%B2FYw-F*VStYP=&ALfRf7jaXN| zDz$HSylUgY3iIT~YZ`Sc$HHJh{Q8)5!d6`&aNoy-L&_s(rK2ZL*595p30KhiYsyNJ zUAgZKiP_T`7SCsxPl9ysJ!2rc`m_VW*n<@omdtm(X;^a)dCU-aG8O}3L6nh;{WJ3t zftZjSJFBo}8FvKjU(s{3F73LmbGrE@+e%BRwF_0qx_@c2hKJrk_|mk1o?JDFe=5bV1C_Bdsxt z8}~D#?`PgssH>V>QBPtKr!z*9^L?&&F5l3Gom8xnZ`LduiiiQl!l1pYXchws;UkV!E&eM!>hOyx$uPf?J^%rra|rKG5bY^UdlS~2O(msSeo zO=Bo668P@Rk)$wbcqn-GhX-OfLY)>4pH@_b8KMEIB%P@tVe^v0Mq@U^&z$h&=dhRt zX)SxA8)Ep&Hu zz1sruP0-CA{FjO;>O=Q>P)tA#p!n494{5x9QljsM!VkV|k*09!;BIn-p*eoN+XKl; zmPb>GU;eAR;8GH@0^is*=G$;s-M5G~{Du`1!7d6($3^Xgxi6rb6}MYIG?3%x+&Is^i^s^= zb4}s&I|_GELNp!pG5?XNR2icpt|}b z#t&Nk1Hj4uxn;nYtEwdRz7tXj(OpDwoq?AT}}6hjk+whrZ-3(>y;&;C{gL!)dr z&%ldP%HTw6P6^05Qp|^zCC)fTtV|d(JPZYXVAny3_phr%nyDXZ3Wb!nUehu|uo=KD zEp7}@t?0R`F(a2H=s_xrf(Q-6NmVn;__CQZX14ubpU_M{tAuRDB#a9cu`GKUEisf6 z5k#a>tM&C(RcV^%i0zIK&ZeX#X2W)+33EaaXsV?R;Eui`ES4&0A^%7Ko!ADpn`|s~sSQ z1IG4J!Rrm+go3?Ar%1_(jE6ydP?GVH;p01J4488hwzsP{50@x`M!N0mMjIYq>*_=F zKM$H;I;o#%XnDrlf;(Wh?XwLaZHeC(=AqmaKpnkecMHpY9w>jnHtZc=9I~19C=jL& zpAwGkq*^CGfF=6Q%fdkA90rRs42%N94}g@uZ22OELqDV(5Idwqs1!ul3eA;C>?;N_ zKB@4>pAzqEwy-&2p?44%8V4q)0b2t7LwYORed_$&P9%mt3@FChFpmHRT`q!!o(CXolFN)ITtHqt3MW>0my71 zHax}A@U(?w3eYvh=>ihV?E>R~ASicCg$hAzZ?e|t4~ju-ewO3K)fOhxE)a||?mW+x zw92C{s44z`AU4%$Cv5k3azh9puo5w3x`ykBx?bf$@<)N#q9L|CVo~Tqvz)s(MG2~u|IR%qvn9_B=aeE>*r<2xOV%SFgmqz}peZ!y{(ozu%jrS9 zrXi+N`CZ_R6!^{X*{=($Gg6%TlD;{8zB3Snq=zgLPlj>yoNxe0O&a3$^%}y?59s8E zNv5)!%EJgYg|2W+Y5r|X1nhX z2ZK0^nDnXKZZc`iZipI5>a}{eU(#>K6<6IT1R^1CQh7J^L=~0d>@hhZRkl!NCONN~ zrso#w5}woP%NGG{H*^$;yl(Mpli1T z`bJcs8$OVfWsi+dq5brAnK?-hP2*DvADBfA z);1GFf1(U`{*{g!zM*I?h7Cu|wJ5>U)PL!QK>rB7F62fYh-7xVwy&KYmOM4oas1k) zoZKiS(+v->BBpTuciAmqA1U_VygI@a-fO*V_s}wcw6Tui>P>~RGUP3Ea%EOgf}kq; z*$LaJ8^zpk;?m=u`2*#OWb)0Hb1n?MR-i8Wdq8^a!?%;(QXY{T9w;r4+dA9?m0TPz z!hon|;2}Fck$ZyZ9hjfR@f` zay*RYxZ=SGZtw0NlTBx?iynHyKc*kD=T_u5@oLU8B6{A66ghR|uVj0C*CLVs4DY z2Tp8bBH-24g@O;}a=dXF)b!5kO1`sx6~ilfF&&LCXPDBHKerSPp0xG*5V4QXz4j@J zh~5&J$Hias6j0-?$r@>CFTr1mM_CQ6Ye`gv`-a80ZTn-}F-!di`NRuTQAg;100000 LNkvXXu0mjfTS-G9 literal 0 HcmV?d00001 diff --git a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx new file mode 100644 index 0000000000..498bcf9063 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx @@ -0,0 +1,260 @@ +import { useQuery } from '@tanstack/react-query'; +import { type ReactNode, useMemo } from 'react'; +import api from '../../../shared/api/api'; +import type { SetupAutoAdoptionResponse } from '../../../shared/api/types'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import type { WizardPageStep } from '../../../shared/components/wizard/types'; +import { WizardPage } from '../../../shared/components/wizard/WizardPage/WizardPage'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { CodeCard } from '../../../shared/defguard-ui/components/CodeCard/CodeCard'; +import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { Icon } from '../../../shared/defguard-ui/components/Icon'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { useClipboard } from '../../../shared/defguard-ui/hooks/useClipboard'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { downloadText } from '../../../shared/utils/download'; +import worldMap from '../assets/world-map.png'; +import { AutoAdoptionAdminUserStep } from './steps/AutoAdoptionAdminUserStep'; +import { AutoAdoptionMfaSetupStep } from './steps/AutoAdoptionMfaSetupStep'; +import { AutoAdoptionSummaryStep } from './steps/AutoAdoptionSummaryStep'; +import { AutoAdoptionUrlSettingsStep } from './steps/AutoAdoptionUrlSettingsStep'; +import { AutoAdoptionVpnSettingsStep } from './steps/AutoAdoptionVpnSettingsStep'; +import { AutoAdoptionSetupStep, type AutoAdoptionSetupStepValue } from './types'; +import { useAutoAdoptionSetupWizardStore } from './useAutoAdoptionSetupWizardStore'; +import './style.scss'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; + +const componentLabel = (component: string): string => { + if (component === 'edge') return 'Edge'; + if (component === 'gateway') return 'Gateway'; + return component; +}; + +const componentLogPrefix = (component: string): string => `[${component.toUpperCase()}]`; + +const formatComponentLogs = (component: string, logs: string[]): string => + logs + .flatMap((line) => line.split(/\r?\n/)) + .filter((line) => line.length > 0) + .map((line) => `${componentLogPrefix(component)} ${line}`) + .join('\n'); + +type AutoAdoptionWelcomeContentProps = { + results: SetupAutoAdoptionResponse['adoption_result']; +}; + +const AutoAdoptionFailedWelcomeContent = ({ + results, +}: AutoAdoptionWelcomeContentProps) => { + const { writeToClipboard } = useClipboard(); + + return ( +
+ +
+

Error summary:

+ +
    +
  • +
    + + Certificate Authority setup successful. +
    +
  • + {Object.entries(results).map(([component, state]) => { + const success = state.success === true; + const componentLogs = formatComponentLogs(component, state.logs); + const showErrorLog = !success && componentLogs.length > 0; + return ( +
  • +
    + + + {componentLabel(component)} setup{' '} + {success ? 'successful' : 'unsuccessful'} + +
    + {showErrorLog && ( +
    + { + void writeToClipboard(componentLogs); + }} + onDownload={() => { + downloadText( + componentLogs, + `auto-adoption-error-log-${component}`, + 'txt', + ); + }} + /> +
    + )} +
  • + ); + })} +
+
+ +
+
+ +

+ If you are a Business or Enterprise customer, please{' '} + contact our support team and provide + the logs you see in the error summary section above. +

+
+
+ +

+ If you are an Open Source or Free plan user, find support on{' '} + + Github Discussions. + +

+
+
+
+ ); +}; + +type AutoAdoptionSuccessWelcomeContentProps = { + onStartFlow: () => void; +}; + +const AutoAdoptionSuccessWelcomeContent = ({ + onStartFlow, +}: AutoAdoptionSuccessWelcomeContentProps) => ( +
+ +

This guide will walk you through the process.

+
+

+ If you would like to understand some basic Defguard concepts, each screen includes + links to documentation as well as short videos with explanations that you can watch + directly during the setup process. +

+ + +
+); + +export const AutoAdoptionSetupPage = () => { + const activeStep = useAutoAdoptionSetupWizardStore((s) => s.activeStep); + const isAutoAdoptionFlowStarted = useAutoAdoptionSetupWizardStore( + (s) => s.isAutoAdoptionFlowStarted, + ); + const startFlow = useAutoAdoptionSetupWizardStore((s) => s.startFlow); + + const { data: statusData } = useQuery({ + queryKey: ['initial_setup', 'auto_adoption', 'status'], + queryFn: api.initial_setup.getAutoAdoptionResult, + select: (response) => response.data, + refetchInterval: 3000, + }); + + const results = statusData?.adoption_result; + + const hasFailedResult = Object.values((isPresent(results) && results) ?? {}).some( + (result) => result.success === false, + ); + + const stepsConfig = useMemo( + (): Record => ({ + adminUser: { + id: AutoAdoptionSetupStep.AdminUser, + order: 1, + label: 'Create Admin User', + description: + 'Manage core details and connection parameters for your VPN location.', + }, + urlSettings: { + id: AutoAdoptionSetupStep.UrlSettings, + order: 2, + label: 'Internal and external URL settings', + description: + 'Manage core details and connection parameters for your VPN location.', + }, + vpnSettings: { + id: AutoAdoptionSetupStep.VpnSettings, + order: 3, + label: 'VPN Public and Internal Settings', + description: + 'Manage core details and connection parameters for your VPN location.', + }, + mfaSetup: { + id: AutoAdoptionSetupStep.MfaSetup, + order: 4, + label: 'Multi-Factor Authentication', + description: 'You can enable Multi-Factor Authentication (MFA) for your VPN.', + }, + summary: { + id: AutoAdoptionSetupStep.Summary, + order: 5, + label: 'Summary', + description: 'Everything is set up and ready to go!', + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + adminUser: , + urlSettings: , + vpnSettings: , + mfaSetup: , + summary: , + }), + [], + ); + + const subtitle = hasFailedResult + ? 'Unfortunately, the automated setup for some components did not complete successfully. Find detailed errors below.' + : 'We have successfully configured all the necessary components (gateway and edge) using Docker for this instance. Now, we need to configure some general settings.'; + + if (!results) { + return null; + } + + return ( + + ) : ( + + ), + media: World map, + displayDocs: false, + }} + > + {stepsComponents[activeStep]} + + ); +}; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionAdminUserStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionAdminUserStep.tsx new file mode 100644 index 0000000000..2d22e3a0d1 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionAdminUserStep.tsx @@ -0,0 +1,281 @@ +import '../style.scss'; + +import { useStore } from '@tanstack/react-form'; +import { useMutation } from '@tanstack/react-query'; +import clsx from 'clsx'; +import { 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 { WizardCard } from '../../../../shared/components/wizard/WizardCard/WizardCard'; +import { Icon } from '../../../../shared/defguard-ui/components/Icon'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +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 { useAppForm, withForm } from '../../../../shared/form'; +import { formChangeLogic } from '../../../../shared/formLogic'; +import { AutoAdoptionSetupStep } from '../types'; +import { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; + +type FormFields = { + first_name: string; + last_name: string; + username: string; + email: string; + password: string; +}; + +const passwordRules = [ + { + id: 'required', + label: m.initial_setup_admin_user_password_rule_required_label(), + message: m.initial_setup_admin_user_password_rule_required_message(), + test: (value: string) => value.length > 0, + apply: (schema: z.ZodString) => + schema.min(1, m.initial_setup_admin_user_password_rule_required_message()), + }, + { + id: 'min', + label: m.initial_setup_admin_user_password_rule_min_label(), + message: m.initial_setup_admin_user_password_rule_min_message(), + test: (value: string) => value.length >= 8, + apply: (schema: z.ZodString) => + schema.min(8, m.initial_setup_admin_user_password_rule_min_message()), + }, + { + id: 'number', + label: m.initial_setup_admin_user_password_rule_number_label(), + message: m.initial_setup_admin_user_password_rule_number_message(), + test: (value: string) => /[0-9]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[0-9]/, m.initial_setup_admin_user_password_rule_number_message()), + }, + { + id: 'special', + label: m.initial_setup_admin_user_password_rule_special_label(), + message: m.initial_setup_admin_user_password_rule_special_message(), + test: (value: string) => /[!@#$%^&*(),.?":{}|<>]/.test(value), + apply: (schema: z.ZodString) => + schema.regex( + /[!@#$%^&*(),.?":{}|<>]/, + m.initial_setup_admin_user_password_rule_special_message(), + ), + }, + { + id: 'lower', + label: m.initial_setup_admin_user_password_rule_lower_label(), + message: m.initial_setup_admin_user_password_rule_lower_message(), + test: (value: string) => /[a-z]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[a-z]/, m.initial_setup_admin_user_password_rule_lower_message()), + }, + { + id: 'upper', + label: m.initial_setup_admin_user_password_rule_upper_label(), + message: m.initial_setup_admin_user_password_rule_upper_message(), + test: (value: string) => /[A-Z]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[A-Z]/, m.initial_setup_admin_user_password_rule_upper_message()), + }, +]; + +const passwordSchema = passwordRules.reduce( + (schema, rule) => rule.apply(schema), + z.string(), +); + +export const AutoAdoptionAdminUserStep = () => { + const setActiveStep = useAutoAdoptionSetupWizardStore((s) => s.setActiveStep); + const defaultValues = useAutoAdoptionSetupWizardStore( + useShallow( + (s): FormFields => ({ + first_name: s.admin_first_name, + last_name: s.admin_last_name, + username: s.admin_username, + email: s.admin_email, + password: s.admin_password, + }), + ), + ); + + const formSchema = useMemo( + () => + z.object({ + first_name: z + .string() + .min(1, m.initial_setup_admin_user_error_first_name_required()), + last_name: z + .string() + .min(1, m.initial_setup_admin_user_error_last_name_required()), + username: z.string().min(3, m.initial_setup_admin_user_error_username_min()), + email: z + .email(m.initial_setup_admin_user_error_email_invalid()) + .min(1, m.initial_setup_admin_user_error_email_required()), + password: passwordSchema, + }), + [], + ); + + const { mutate, isPending } = useMutation({ + mutationFn: api.initial_setup.createAdminUser, + meta: { + invalidate: ['setupStatus'], + }, + onSuccess: () => { + setActiveStep(AutoAdoptionSetupStep.UrlSettings); + }, + onError: (error) => { + Snackbar.error(m.initial_setup_admin_user_error_create_failed()); + console.error('Failed to create admin user:', error); + }, + }); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useAutoAdoptionSetupWizardStore.setState({ + admin_first_name: value.first_name, + admin_last_name: value.last_name, + admin_username: value.username, + admin_email: value.email, + admin_password: value.password, + }); + mutate({ + first_name: value.first_name, + last_name: value.last_name, + username: value.username, + email: value.email, + password: value.password, + automatically_assign_group: true, + }); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + className="setup-admin-user" + > + +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+ + {(field) => ( + + )} + + + +
+
+ +
+
+ +
+ ); +}; + +const PasswordChecklist = withForm({ + defaultValues: { + first_name: '', + last_name: '', + username: '', + email: '', + password: '', + }, + render: ({ form }) => { + const password = useStore(form.store, (state) => state.values.password ?? ''); + const isPristine = useStore( + form.store, + (state) => state.fieldMeta.password?.isPristine ?? true, + ); + + const checks = passwordRules.map((rule) => ({ + id: rule.id, + label: rule.label, + passed: rule.test(password), + })); + + return ( +
+

{m.initial_setup_admin_user_password_checklist_title()}

+
    + {checks.map((item) => { + const checked = !isPristine && item.passed; + const iconKind = checked ? 'check-filled' : 'empty-point'; + + return ( +
  • + + {item.label} +
  • + ); + })} +
+
+ ); + }, +}); diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionMfaSetupStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionMfaSetupStep.tsx new file mode 100644 index 0000000000..f8b99fdfb8 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionMfaSetupStep.tsx @@ -0,0 +1,80 @@ +import { useMutation } from '@tanstack/react-query'; +import api from '../../../../shared/api/api'; +import { LocationMfaMode } from '../../../../shared/api/types'; +import { businessBadgeProps } from '../../../../shared/components/badges/BusinessBadge'; +import { WizardCard } from '../../../../shared/components/wizard/WizardCard/WizardCard'; +import { InfoBanner } from '../../../../shared/defguard-ui/components/InfoBanner/InfoBanner'; +import { InteractiveBlock } from '../../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; +import { AutoAdoptionSetupStep } from '../types'; +import { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; + +export const AutoAdoptionMfaSetupStep = () => { + const setActiveStep = useAutoAdoptionSetupWizardStore((s) => s.setActiveStep); + const mfaMode = useAutoAdoptionSetupWizardStore((s) => s.vpn_mfa_mode); + + const { mutate: setMfaSettings, isPending } = useMutation({ + mutationFn: api.initial_setup.setAutoAdoptionMfaSettings, + onSuccess: () => { + setActiveStep(AutoAdoptionSetupStep.Summary); + }, + }); + + const setMfaMode = (mode: (typeof LocationMfaMode)[keyof typeof LocationMfaMode]) => { + useAutoAdoptionSetupWizardStore.setState({ vpn_mfa_mode: mode }); + }; + + return ( + +
+ setMfaMode(LocationMfaMode.Disabled)} + title="Do not enforce MFA" + /> + + setMfaMode(LocationMfaMode.Internal)} + title="Internal Defguard Multi-Factor Authentication" + content="Uses the MFA methods configured in your Defguard profile." + > + {mfaMode === LocationMfaMode.Internal && ( + <> + + + + )} + + + +
+ setActiveStep(AutoAdoptionSetupStep.VpnSettings), + }} + submitProps={{ + text: 'Continue', + onClick: () => { + setMfaSettings({ vpn_mfa_mode: mfaMode }); + }, + loading: isPending, + }} + /> +
+ ); +}; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionSummaryStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionSummaryStep.tsx new file mode 100644 index 0000000000..34698d4d61 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionSummaryStep.tsx @@ -0,0 +1,158 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import api from '../../../../shared/api/api'; +import { WizardCard } from '../../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../../shared/defguard-ui/components/Button/Button'; +import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divider'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +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 { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; +import './style.scss'; + +import CommunityIcon from '../../assets/community.png'; +import FileIcon from '../../assets/file-icon.png'; +import ShieldIcon from '../../assets/shield.png'; + +export const AutoAdoptionSummaryStep = () => { + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const wireguardPort = useAutoAdoptionSetupWizardStore((s) => s.vpn_wireguard_port); + + const waitForSettingsEssentials = async ({ + timeoutMs = 60_000, + intervalMs = 500, + }: { + timeoutMs?: number; + intervalMs?: number; + }) => { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await api.settings.getSettingsEssentials(); + + if (isPresent(response.data) && response.data.initial_setup_completed) { + return; + } + } catch (_error) { + // Ignore errors while API restarts. + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Timed out waiting for settings essentials.'); + }; + + const { mutateAsync: finishSetup } = useMutation({ + mutationKey: ['finish-setup'], + mutationFn: api.initial_setup.finishSetup, + meta: { + invalidate: ['settings_essentials'], + }, + }); + + const handleGoToDefguard = async () => { + try { + setIsSubmitting(true); + await finishSetup(); + await waitForSettingsEssentials({}); + await navigate({ to: '/vpn-overview', replace: true }); + setTimeout(() => { + useAutoAdoptionSetupWizardStore.getState().reset(); + }, 100); + } catch (error) { + console.error('Failed to finish setup flow:', error); + Snackbar.error('Failed to finish setup.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +

Thank you for choosing Defguard.

+ +

+ Please note that if the host running Defguard is not publicly accessible (i.e., it + does not have the VPN public IP assigned to it), you must forward the following + ports to it: +

+ +
    +
  • TCP ports 80 and 443
  • +
  • UDP port {wireguardPort}
  • +
+ +

We would encourage you to:

+ +
+
+ Documentation Icon +
+
+

Defguard insides

+

+ Get familiar with our security concepts and architecture +

+
+
+
+ +
+ Community Icon +
+
+

Join our community

+

Join our community and participate in discussion

+
+
+
+ +
+ Security Icon +
+
+

Support Us

+

Star us on GitHub

+
+
+
+
+ + +
+ ); +}; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx new file mode 100644 index 0000000000..cbb6980a2e --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -0,0 +1,137 @@ +import { useMutation } from '@tanstack/react-query'; +import { 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 { WizardCard } from '../../../../shared/components/wizard/WizardCard/WizardCard'; +import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divider'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +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 { useAppForm } from '../../../../shared/form'; +import { formChangeLogic } from '../../../../shared/formLogic'; +import { AutoAdoptionSetupStep } from '../types'; +import { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; +import './style.scss'; + +type FormFields = { + defguard_url: string; + public_proxy_url: string; +}; + +export const AutoAdoptionUrlSettingsStep = () => { + const setActiveStep = useAutoAdoptionSetupWizardStore((s) => s.setActiveStep); + const defaultValues = useAutoAdoptionSetupWizardStore( + useShallow( + (s): FormFields => ({ + defguard_url: s.defguard_url, + public_proxy_url: s.public_proxy_url, + }), + ), + ); + + const formSchema = useMemo( + () => + z.object({ + defguard_url: z + .url(m.initial_setup_general_config_error_invalid_url()) + .min(1, m.initial_setup_general_config_error_defguard_url_required()), + public_proxy_url: z + .url(m.initial_setup_general_config_error_public_proxy_url_invalid()) + .min(1, m.initial_setup_general_config_error_public_proxy_url_required()), + }), + [], + ); + + const { mutate, isPending } = useMutation({ + mutationFn: api.initial_setup.setAutoAdoptionUrlSettings, + meta: { + invalidate: ['setupStatus'], + }, + onSuccess: () => { + setActiveStep(AutoAdoptionSetupStep.VpnSettings); + }, + onError: (error) => { + Snackbar.error(m.initial_setup_general_config_error_save_failed()); + console.error('Failed to save URL settings:', error); + }, + }); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useAutoAdoptionSetupWizardStore.setState({ + defguard_url: value.defguard_url, + public_proxy_url: value.public_proxy_url, + }); + + mutate({ + defguard_url: value.defguard_url, + public_proxy_url: value.public_proxy_url, + }); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + +

+ This URL will be used to access and control Defguard. It should not be exposed + to the Internet only to the internal or VPN network. You can learn more about + our security approach in the video below. +

+ + + {(field) => ( + + )} + + +

+ We have deployed a secure Edge component that handles various tasks, such as + enabling automated user enrollment and sending automated configuration updates + to desktop and mobile clients. It requires a dedicated URL and must be + publicly accessible on the Internet. You can change public URL later in + General Settings. Learn more about Edge component in the video guide on the + left. +

+ + + {(field) => ( + + )} + +
+
+ +
+ ); +}; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionVpnSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionVpnSettingsStep.tsx new file mode 100644 index 0000000000..c5be763edb --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionVpnSettingsStep.tsx @@ -0,0 +1,152 @@ +import { useMutation } from '@tanstack/react-query'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import api from '../../../../shared/api/api'; +import { WizardCard } from '../../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../../shared/defguard-ui/components/Button/Button'; +import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divider'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; +import { useAppForm } from '../../../../shared/form'; +import { formChangeLogic } from '../../../../shared/formLogic'; +import { Validate } from '../../../../shared/validate'; +import { AutoAdoptionSetupStep } from '../types'; +import { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; +import './style.scss'; + +const formSchema = z.object({ + vpn_public_ip: z + .string() + .trim() + .min(1, 'Required') + .refine( + (value) => Validate.any(value, [Validate.IPv4, Validate.IPv6, Validate.Domain]), + 'Invalid value', + ), + vpn_wireguard_port: z.number().min(1, 'Required').max(65535, 'Port is too large'), + vpn_gateway_address: z + .string() + .trim() + .min(1, 'Required') + .refine( + (value) => Validate.any(value, [Validate.CIDRv4, Validate.CIDRv6], true), + 'Invalid value', + ), + vpn_allowed_ips: z.string().trim(), + vpn_dns_server_ip: z.string().trim(), +}); + +type FormFields = z.infer; + +export const AutoAdoptionVpnSettingsStep = () => { + const setActiveStep = useAutoAdoptionSetupWizardStore((s) => s.setActiveStep); + + const { mutate: setVpnSettings, isPending } = useMutation({ + mutationFn: api.initial_setup.setAutoAdoptionVpnSettings, + onSuccess: () => { + setActiveStep(AutoAdoptionSetupStep.MfaSetup); + }, + }); + const defaultValues = useAutoAdoptionSetupWizardStore( + useShallow( + (s): FormFields => ({ + vpn_public_ip: s.vpn_public_ip, + vpn_wireguard_port: s.vpn_wireguard_port, + vpn_gateway_address: s.vpn_gateway_address, + vpn_allowed_ips: s.vpn_allowed_ips, + vpn_dns_server_ip: s.vpn_dns_server_ip, + }), + ), + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useAutoAdoptionSetupWizardStore.setState(value); + setVpnSettings(value); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + +

+ To make the VPN operational, a few basic parameters must be configured. + WireGuard® needs to be publicly accessible on a specific IP address and UDP + port. This IP does not have to be set directly on the gateway it can be + configured on your firewall or router and forwarded to the Defguard Gateway. +

+ +
+ + {(field) => } + + + {(field) => ( + + )} + +
+ +

+ Please provide the internal VPN network IP address for the Defguard Gateway. + The VPN network will be derived from this address (e.g., 10.10.10.1 → + 10.10.10.0). You may specify multiple addresses separated by commas; the first + will be used as the primary address for device IP assignment. +

+ + + {(field) => } + + +

+ If you want your local networks to be accessible from VPN, list them in + addresses/masks format below: +

+ + + {(field) => } + + +

+ Configure (optionally) a custom DNS server for VPN connections (e.g., your + local network DNS or a preferred DNS to use while connected to the VPN). +

+ + + {(field) => } + + +