From a327c73c7a12b04bddeddc7ad39da7a22b1752ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 9 Mar 2026 14:49:30 +0100 Subject: [PATCH 01/14] init migration of locations during migration wizard --- crates/defguard_core/src/handlers/mod.rs | 1 + .../src/handlers/resource_display.rs | 23 ++++ crates/defguard_core/src/lib.rs | 2 + crates/defguard_setup/src/migration.rs | 8 +- crates/defguard_setup/src/setup_server.rs | 6 +- docker-compose.yaml | 24 ++++ .../GatewaySetupPage/GatewaySetupPage.tsx | 25 ++-- .../steps/SetupConfirmationStep.tsx | 53 +++++++- .../useGatewayWizardStore.tsx | 2 + .../LocationsMigrationWizardPage.tsx | 115 ++++++++++++++++++ .../MigrationWizardPage.tsx | 6 +- .../MigrationWizardConfirmationStep.tsx | 103 +++++++++++----- .../store/useMigrationWizardStore.tsx | 44 +++++-- web/src/routeTree.gen.ts | 103 +++++++++------- .../_authorized/_wizard/setup-gateway.tsx | 6 - .../{migration.tsx => migration/index.tsx} | 19 ++- .../routes/_wizard/migration/locations.tsx | 30 +++++ web/src/routes/_wizard/setup-gateway.tsx | 35 ++++++ web/src/shared/api/api.ts | 2 + web/src/shared/api/types.ts | 11 ++ .../wizard/WizardPage/WizardPage.tsx | 17 ++- .../WizardWelcomePage/WizardWelcomePage.tsx | 16 ++- .../WizardWelcomePage/assets/world_map.png | Bin 0 -> 150561 bytes .../wizard/WizardWelcomePage/style.scss | 6 + web/src/shared/components/wizard/types.ts | 3 + web/src/shared/query.ts | 7 ++ web/src/shared/utils/resourceById.ts | 16 ++- .../wizard/migrationWizardFinishPromise.tsx | 16 +++ 28 files changed, 582 insertions(+), 117 deletions(-) create mode 100644 crates/defguard_core/src/handlers/resource_display.rs create mode 100644 web/src/pages/LocationsMigrationWizardPage/LocationsMigrationWizardPage.tsx delete mode 100644 web/src/routes/_authorized/_wizard/setup-gateway.tsx rename web/src/routes/_wizard/{migration.tsx => migration/index.tsx} (53%) create mode 100644 web/src/routes/_wizard/migration/locations.tsx create mode 100644 web/src/routes/_wizard/setup-gateway.tsx create mode 100644 web/src/shared/components/wizard/WizardWelcomePage/assets/world_map.png create mode 100644 web/src/shared/wizard/migrationWizardFinishPromise.tsx diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 2d5e1f4850..067c876832 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 mod resource_display; pub mod session_info; pub mod settings; pub(crate) mod ssh_authorized_keys; diff --git a/crates/defguard_core/src/handlers/resource_display.rs b/crates/defguard_core/src/handlers/resource_display.rs new file mode 100644 index 0000000000..52f07204bb --- /dev/null +++ b/crates/defguard_core/src/handlers/resource_display.rs @@ -0,0 +1,23 @@ +use axum::{Extension, http::StatusCode}; +use sqlx::FromRow; + +use super::{ApiResponse, ApiResult}; +use crate::auth::AdminRole; + +#[derive(Serialize, FromRow, Debug)] +pub struct ResourceDisplay { + pub id: i64, + pub display: String, +} + +pub async fn get_locations_display( + _admin: AdminRole, + Extension(pool): Extension, +) -> ApiResult { + let resources = + sqlx::query_as::<_, ResourceDisplay>("SELECT id, name AS display FROM wireguard_network") + .fetch_all(&pool) + .await?; + + Ok(ApiResponse::json(resources, StatusCode::OK)) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 362366d7d7..6113302eb9 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -141,6 +141,7 @@ use crate::{ userinfo, }, proxy::{delete_proxy, proxy_details, proxy_list, update_proxy}, + resource_display::get_locations_display, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, test_ldap_settings, update_settings, @@ -533,6 +534,7 @@ pub fn build_webapp( post(start_network_device_setup_for_device), ) .route("/network", post(create_network).get(list_networks)) + .route("/network/display", get(get_locations_display)) .route("/network/import", post(import_network)) .route("/network/stats", get(locations_overview_stats)) .route("/network/gateways", get(all_gateways_status)) diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index 4b452f6df0..44f5c80bb7 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -20,7 +20,8 @@ use defguard_core::{ mfa_enable, recovery_code, request_email_mfa_code, totp_code, totp_enable, totp_secret, webauthn_end, webauthn_finish, webauthn_init, webauthn_start, }, - component_setup::setup_proxy_tls_stream, + component_setup::{setup_gateway_tls_stream, setup_proxy_tls_stream}, + resource_display::get_locations_display, session_info::get_session_info, settings::{get_settings, get_settings_essentials, patch_settings}, wireguard::list_networks, @@ -121,6 +122,11 @@ pub fn build_migration_webapp( .route("/auth/email/verify", post(email_mfa_code)) .route("/auth/recovery", post(recovery_code)) .route("/network", get(list_networks)) + .route("/network/display", get(get_locations_display)) + .route( + "/network/{network_id}/gateways/setup", + get(setup_gateway_tls_stream), + ) .nest( "/migration", Router::new() diff --git a/crates/defguard_setup/src/setup_server.rs b/crates/defguard_setup/src/setup_server.rs index c8d1240f23..97eee0d54b 100644 --- a/crates/defguard_setup/src/setup_server.rs +++ b/crates/defguard_setup/src/setup_server.rs @@ -13,7 +13,10 @@ use defguard_common::VERSION; use defguard_core::{ auth::failed_login::FailedLoginMap, handle_404, - handlers::{component_setup::setup_proxy_tls_stream, settings::get_settings_essentials}, + handlers::{ + component_setup::setup_proxy_tls_stream, resource_display::get_locations_display, + settings::get_settings_essentials, + }, health_check, }; use defguard_web_ui::{index, svg, web_asset}; @@ -45,6 +48,7 @@ pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sen .route("/health", get(health_check)) .route("/settings_essentials", get(get_settings_essentials)) .route("/session-info", get(get_session_info)) + .route("/network/display", get(get_locations_display)) .route("/wizard", get(get_wizard_state)) .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) .nest( diff --git a/docker-compose.yaml b/docker-compose.yaml index 8cf2fd08f1..6124f8a80e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,23 @@ services: + proxy: + image: ghcr.io/defguard/defguard-proxy:dev + ports: + - "50051:50051" + + openldap: + image: bitnamilegacy/openldap:2.6 + user: root + restart: unless-stopped + environment: + LDAP_ADMIN_PASSWORD: "pass123" + ports: + - "389:1389" + volumes: + - ./ldap/entrypoint:/docker-entrypoint-initdb.d:ro + - ./ldap/init.ldif:/ldifs/init.ldif:ro + - ./ldap/custom.ldif:/schema/custom.ldif:ro + - ./.volumes/openldap:/bitnami/openldap + core: image: ghcr.io/defguard/defguard build: @@ -50,6 +69,11 @@ services: - ./.volumes/db:/var/lib/postgresql/data ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 5s + retries: 5 device: build: diff --git a/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx b/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx index f19acfe340..4a9b244f12 100644 --- a/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx +++ b/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx @@ -1,6 +1,6 @@ import './style.scss'; import { useNavigate } from '@tanstack/react-router'; -import { type ReactNode, useMemo } from 'react'; +import { type ReactNode, useCallback, useMemo } from 'react'; import { m } from '../../paraglide/messages'; import { Controls } from '../../shared/components/Controls/Controls'; import type { WizardPageStep } from '../../shared/components/wizard/types'; @@ -20,8 +20,23 @@ export const GatewaySetupPage = () => { const activeStep = useGatewayWizardStore((s) => s.activeStep); const isOnWelcomePage = useGatewayWizardStore((s) => s.isOnWelcomePage); const setIsOnWelcomePage = useGatewayWizardStore((s) => s.setisOnWelcomePage); + const isMigrationWizard = useGatewayWizardStore((s) => s.isMigrationWizard); const navigate = useNavigate(); + const handleOnClose = useCallback(() => { + navigate({ to: '/locations', replace: true }).then(() => { + setTimeout(() => { + useGatewayWizardStore.getState().reset(); + }, 100); + }); + }, [navigate]); + + // when is part of migration wizard, closing should be disabled + const onClose = useMemo(() => { + if (isMigrationWizard) return undefined; + return handleOnClose; + }, [handleOnClose, isMigrationWizard]); + const stepsConfig = useMemo( (): Record => ({ deployGateway: { @@ -79,13 +94,7 @@ export const GatewaySetupPage = () => { return ( { - navigate({ to: '/locations', replace: true }).then(() => { - setTimeout(() => { - useGatewayWizardStore.getState().reset(); - }, 100); - }); - }} + onClose={onClose} subtitle={m.gateway_setup_page_subtitle()} title={m.gateway_setup_page_title()} steps={stepsConfig} diff --git a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx index c0d2883166..67c2c84f47 100644 --- a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx +++ b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx @@ -1,15 +1,29 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; +import { cloneDeep } from 'radashi'; +import { useCallback } from 'react'; import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import type { MigrationWizardLocationState } from '../../../shared/api/types'; import { ActionCard } from '../../../shared/components/ActionCard/ActionCard'; 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 { + getMigrationStateQueryOptions, + getSessionInfoQueryOptions, +} from '../../../shared/query'; +import { migrationWizardFinishPromise } from '../../../shared/wizard/migrationWizardFinishPromise'; +import { useMigrationWizardStore } from '../../MigrationWizardPage/store/useMigrationWizardStore'; +import { MigrationWizardStep } from '../../MigrationWizardPage/types'; import addMoreImage from '../assets/add_more.svg'; import { useGatewayWizardStore } from '../useGatewayWizardStore'; export const SetupConfirmationStep = () => { + const queryClient = useQueryClient(); const navigate = useNavigate(); const handleBack = () => { @@ -18,12 +32,45 @@ export const SetupConfirmationStep = () => { useGatewayWizardStore.getState().start({ network_id: networkId }); }; - const handleFinish = async () => { - await navigate({ to: '/locations', replace: true }); + const handleFinish = useCallback(async () => { + if (useGatewayWizardStore.getState().isMigrationWizard) { + const locationState = cloneDeep( + useMigrationWizardStore.getState().location_state as MigrationWizardLocationState, + ); + // finish migration + if (locationState.current_location === locationState.locations.length - 1) { + await migrationWizardFinishPromise(); + await queryClient.invalidateQueries({ + queryKey: getSessionInfoQueryOptions.queryKey, + }); + Snackbar.success(`Migration completed`); + await navigate({ to: '/vpn-overview', replace: true }); + setTimeout(() => { + useMigrationWizardStore.getState().resetState(); + }, 2500); + return; + } + // otherwise open next location migration + locationState.current_location + 1; + await api.migration.state.updateMigrationState({ + current_step: MigrationWizardStep.Confirmation, + location_state: locationState, + }); + await queryClient.invalidateQueries({ + queryKey: getMigrationStateQueryOptions.queryKey, + }); + useMigrationWizardStore.setState({ + location_state: locationState, + }); + await navigate({ to: '/migration/locations', replace: true }); + return; + } else { + await navigate({ to: '/locations', replace: true }); + } setTimeout(() => { useGatewayWizardStore.getState().reset(); }, 100); - }; + }, [navigate, queryClient]); return ( diff --git a/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx b/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx index c9be9cfb8e..bd97f53d49 100644 --- a/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx +++ b/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx @@ -14,6 +14,7 @@ type GatewayAdoptionState = { }; type StoreValues = { + isMigrationWizard: boolean; activeStep: GatewaySetupStepValue; isOnWelcomePage: boolean; common_name: string; @@ -42,6 +43,7 @@ const gatewayAdoptionStateDefaults: GatewayAdoptionState = { }; const defaults: StoreValues = { + isMigrationWizard: false, activeStep: GatewaySetupStep.DeployGateway, isOnWelcomePage: true, common_name: '', diff --git a/web/src/pages/LocationsMigrationWizardPage/LocationsMigrationWizardPage.tsx b/web/src/pages/LocationsMigrationWizardPage/LocationsMigrationWizardPage.tsx new file mode 100644 index 0000000000..1e29c415c6 --- /dev/null +++ b/web/src/pages/LocationsMigrationWizardPage/LocationsMigrationWizardPage.tsx @@ -0,0 +1,115 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { cloneDeep } from 'radashi'; +import { useCallback } from 'react'; +import Skeleton from 'react-loading-skeleton'; +import api from '../../shared/api/api'; +import { Controls } from '../../shared/components/Controls/Controls'; +import { WizardWelcomePage } from '../../shared/components/wizard/WizardWelcomePage/WizardWelcomePage'; +import { AppText } from '../../shared/defguard-ui/components/AppText/AppText'; +import { Button } from '../../shared/defguard-ui/components/Button/Button'; +import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { TextStyle, ThemeSpacing, ThemeVariable } from '../../shared/defguard-ui/types'; +import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import { getLocationsDisplayQueryOptions } from '../../shared/query'; +import { migrationWizardFinishPromise } from '../../shared/wizard/migrationWizardFinishPromise'; +import { useGatewayWizardStore } from '../GatewaySetupPage/useGatewayWizardStore'; +import { useMigrationWizardStore } from '../MigrationWizardPage/store/useMigrationWizardStore'; +import { MigrationWizardStep } from '../MigrationWizardPage/types'; + +const subtitle = `We will verify the connection, ensure the port is open, and confirm the gateway is running the correct version. Any errors will be displayed, allowing you to fix issues and retry during the process.`; + +export const LocationsMigrationWizardPage = () => { + return ( + } + /> + ); +}; + +const Content = () => { + const navigate = useNavigate(); + const { data: locationsDisplay, isLoading } = useQuery(getLocationsDisplayQueryOptions); + + const { mutate: updateWizardState } = useMutation({ + mutationFn: api.migration.state.updateMigrationState, + meta: { + invalidate: ['migration'], + }, + }); + + const { mutate: finish, isPending: finishPending } = useMutation({ + mutationFn: migrationWizardFinishPromise, + meta: { + invalidate: ['session-info'], + }, + }); + + const locationsState = useMigrationWizardStore((s) => s.location_state); + + const handleStart = useCallback(() => { + if (!locationsState) return; + useGatewayWizardStore.getState().start({ + isMigrationWizard: true, + network_id: locationsState.locations[locationsState.current_location], + }); + navigate({ to: '/setup-gateway', replace: true }); + }, [locationsState, navigate]); + + const handleSkip = useCallback(() => { + if (!locationsState) return; + if (locationsState.current_location === locationsState.locations.length - 1) { + finish(); + return; + } + const state = cloneDeep(locationsState); + state.current_location + 1; + updateWizardState({ + current_step: MigrationWizardStep.Confirmation, + location_state: state, + }); + }, [locationsState, updateWizardState, finish]); + + if (!locationsState) return null; + + return ( + <> + + + {`By clicking the button bellow, you confirm that the required firewall changes have been made and that the Core can connect to this gateway on TCP port 5055. In case you have any question please read our documentation following the link in the bottom section.`} + + + + {`Migrate ${locationsState.current_location + 1} of ${locationsState.locations.length} location(s):`} + + {isLoading && } + {!isLoading && isPresent(locationsDisplay) && ( + + {locationsDisplay[locationsState.current_location] ?? `Unknown`} + + )} + + +