diff --git a/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json b/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json index a86855aeb9..6bac2b9d11 100644 --- a/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json +++ b/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json @@ -66,7 +66,7 @@ { "ordinal": 12, "name": "modified_by", - "type_info": "Int8" + "type_info": "Text" } ], "parameters": { diff --git a/.sqlx/query-a32d8d7edbd9e9054e082d1e98ec4d6f2ba85dcd98e84d177c3d3148143ad9fe.json b/.sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json similarity index 70% rename from .sqlx/query-a32d8d7edbd9e9054e082d1e98ec4d6f2ba85dcd98e84d177c3d3148143ad9fe.json rename to .sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json index bda48d4553..2f03d82e6a 100644 --- a/.sqlx/query-a32d8d7edbd9e9054e082d1e98ec4d6f2ba85dcd98e84d177c3d3148143ad9fe.json +++ b/.sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "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 WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false 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 WHERE location_id = $1", + "query": "SELECT gateway.*, CASE WHEN gateway.connected_at IS NULL THEN false WHEN gateway.disconnected_at IS NULL THEN true WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false END AS \"connected!\", wn.name AS location_name FROM gateway JOIN wireguard_network wn ON gateway.location_id = wn.id WHERE location_id = $1", "describe": { "columns": [ { @@ -60,31 +60,21 @@ }, { "ordinal": 11, - "name": "modified_by", - "type_info": "Int8" - }, - { - "ordinal": 12, "name": "enabled", "type_info": "Bool" }, { - "ordinal": 13, - "name": "modified_by_firstname", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "modified_by_lastname", + "ordinal": 12, + "name": "modified_by", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 13, "name": "connected!", "type_info": "Bool" }, { - "ordinal": 16, + "ordinal": 14, "name": "location_name", "type_info": "Text" } @@ -108,11 +98,9 @@ false, false, false, - false, - false, null, false ] }, - "hash": "a32d8d7edbd9e9054e082d1e98ec4d6f2ba85dcd98e84d177c3d3148143ad9fe" + "hash": "127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf" } diff --git a/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json b/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json index de746cbc64..aa509dfc3e 100644 --- a/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json +++ b/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json @@ -50,12 +50,12 @@ }, { "ordinal": 9, - "name": "modified_by", - "type_info": "Int8" + "name": "certificate", + "type_info": "Text" }, { "ordinal": 10, - "name": "certificate", + "name": "modified_by", "type_info": "Text" }, { @@ -77,8 +77,8 @@ true, true, false, - false, true, + false, false ] }, diff --git a/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json b/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json index 9cd0911d75..fc05918a2f 100644 --- a/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json +++ b/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json @@ -60,13 +60,13 @@ }, { "ordinal": 11, - "name": "modified_by", - "type_info": "Int8" + "name": "enabled", + "type_info": "Bool" }, { "ordinal": 12, - "name": "enabled", - "type_info": "Bool" + "name": "modified_by", + "type_info": "Text" } ], "parameters": { diff --git a/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json b/.sqlx/query-44ff10f2f1d9c56e8a6f1f24983a4c886abdce0170da181a51550fca3d921c58.json similarity index 90% rename from .sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json rename to .sqlx/query-44ff10f2f1d9c56e8a6f1f24983a4c886abdce0170da181a51550fca3d921c58.json index 0d320da8d5..c3f075d0b2 100644 --- a/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json +++ b/.sqlx/query-44ff10f2f1d9c56e8a6f1f24983a4c886abdce0170da181a51550fca3d921c58.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", default_admin_id FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -292,57 +292,31 @@ }, { "ordinal": 51, - "name": "initial_setup_completed", - "type_info": "Bool" - }, - { - "ordinal": 52, "name": "defguard_url", "type_info": "Text" }, { - "ordinal": 53, + "ordinal": 52, "name": "default_admin_group_name", "type_info": "Text" }, { - "ordinal": 54, + "ordinal": 53, "name": "authentication_period_days", "type_info": "Int4" }, { - "ordinal": 55, + "ordinal": 54, "name": "mfa_code_timeout_seconds", "type_info": "Int4" }, { - "ordinal": 56, + "ordinal": 55, "name": "public_proxy_url", "type_info": "Text" }, { - "ordinal": 57, - "name": "initial_setup_step: InitialSetupStep", - "type_info": { - "Custom": { - "name": "initial_setup_step", - "kind": { - "Enum": [ - "welcome", - "admin_user", - "general_configuration", - "ca", - "ca_summary", - "edge_component", - "confirmation", - "finished" - ] - } - } - } - }, - { - "ordinal": 58, + "ordinal": 56, "name": "default_admin_id", "type_info": "Int8" } @@ -407,10 +381,8 @@ false, false, false, - false, - false, true ] }, - "hash": "080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5" + "hash": "44ff10f2f1d9c56e8a6f1f24983a4c886abdce0170da181a51550fca3d921c58" } diff --git a/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json b/.sqlx/query-470e8a68d2388ec92ced92198bafc9a15e83843b795918b3aa983565bb062037.json similarity index 81% rename from .sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json rename to .sqlx/query-470e8a68d2388ec92ced92198bafc9a15e83843b795918b3aa983565bb062037.json index 94fd3166a1..524ade485e 100644 --- a/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json +++ b/.sqlx/query-470e8a68d2388ec92ced92198bafc9a15e83843b795918b3aa983565bb062037.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, initial_setup_completed = $52, defguard_url = $53, default_admin_group_name = $54, authentication_period_days = $55, mfa_code_timeout_seconds = $56, public_proxy_url = $57, initial_setup_step = $58, default_admin_id = $59 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -88,33 +88,15 @@ "Bytea", "Bytea", "Timestamp", - "Bool", "Text", "Text", "Int4", "Int4", "Text", - { - "Custom": { - "name": "initial_setup_step", - "kind": { - "Enum": [ - "welcome", - "admin_user", - "general_configuration", - "ca", - "ca_summary", - "edge_component", - "confirmation", - "finished" - ] - } - } - }, "Int8" ] }, "nullable": [] }, - "hash": "32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e" + "hash": "470e8a68d2388ec92ced92198bafc9a15e83843b795918b3aa983565bb062037" } diff --git a/.sqlx/query-1e48e6b87c058b8dbc54b86c704ee3ecbfdfbd69f3d44e16d9a6e9ef25069614.json b/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json similarity index 75% rename from .sqlx/query-1e48e6b87c058b8dbc54b86c704ee3ecbfdfbd69f3d44e16d9a6e9ef25069614.json rename to .sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json index 9aa06dfed1..68e6a6c079 100644 --- a/.sqlx/query-1e48e6b87c058b8dbc54b86c704ee3ecbfdfbd69f3d44e16d9a6e9ef25069614.json +++ b/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "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", + "query": "SELECT * FROM proxy", "describe": { "columns": [ { @@ -50,28 +50,18 @@ }, { "ordinal": 9, - "name": "modified_by", - "type_info": "Int8" + "name": "certificate", + "type_info": "Text" }, { "ordinal": 10, - "name": "certificate", + "name": "modified_by", "type_info": "Text" }, { "ordinal": 11, "name": "enabled", "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "modified_by_firstname", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "modified_by_lastname", - "type_info": "Text" } ], "parameters": { @@ -87,12 +77,10 @@ true, true, false, - false, true, false, - false, false ] }, - "hash": "1e48e6b87c058b8dbc54b86c704ee3ecbfdfbd69f3d44e16d9a6e9ef25069614" + "hash": "472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506" } diff --git a/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json b/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json index 65ae6a26e1..0f433d31da 100644 --- a/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json +++ b/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json @@ -60,13 +60,13 @@ }, { "ordinal": 11, - "name": "modified_by", - "type_info": "Int8" + "name": "enabled", + "type_info": "Bool" }, { "ordinal": 12, - "name": "enabled", - "type_info": "Bool" + "name": "modified_by", + "type_info": "Text" } ], "parameters": { diff --git a/.sqlx/query-638da4de4db75b1175ae814a9b993c06006c792af9cda1b1221e5c3640c2a9a3.json b/.sqlx/query-638da4de4db75b1175ae814a9b993c06006c792af9cda1b1221e5c3640c2a9a3.json new file mode 100644 index 0000000000..9ba5a7da00 --- /dev/null +++ b/.sqlx/query-638da4de4db75b1175ae814a9b993c06006c792af9cda1b1221e5c3640c2a9a3.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active_wizard AS \"active_wizard!: ActiveWizard\", completed, initial_setup_state AS \"initial_setup_state: Json\", auto_adoption_state AS \"auto_adoption_state: Json\", migration_wizard_state AS \"migration_wizard_state: Json\" FROM wizard WHERE is_singleton = TRUE LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active_wizard!: ActiveWizard", + "type_info": { + "Custom": { + "name": "active_wizard", + "kind": { + "Enum": [ + "none", + "initial", + "auto_adoption", + "migration" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "completed", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "initial_setup_state: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "auto_adoption_state: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "migration_wizard_state: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + true, + true, + true + ] + }, + "hash": "638da4de4db75b1175ae814a9b993c06006c792af9cda1b1221e5c3640c2a9a3" +} diff --git a/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json b/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json index 9ab462443a..0703ef2639 100644 --- a/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json +++ b/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json @@ -17,7 +17,7 @@ "Text", "Bool", "Timestamp", - "Int8" + "Text" ] }, "nullable": [] diff --git a/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json b/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json index 84dc8a364b..332b4b8b4e 100644 --- a/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json +++ b/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json @@ -16,7 +16,7 @@ "Text", "Timestamp", "Timestamp", - "Int8" + "Text" ] }, "nullable": [] diff --git a/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json b/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json index 592a365bbd..4ea6dccd4a 100644 --- a/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json +++ b/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json @@ -22,7 +22,7 @@ "Text", "Bool", "Timestamp", - "Int8" + "Text" ] }, "nullable": [ diff --git a/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json b/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json index 989d93d995..2b5f406c85 100644 --- a/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json +++ b/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json @@ -21,7 +21,7 @@ "Text", "Timestamp", "Timestamp", - "Int8" + "Text" ] }, "nullable": [ diff --git a/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json index a7235190f8..87ce4e720e 100644 --- a/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json +++ b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json @@ -50,12 +50,12 @@ }, { "ordinal": 9, - "name": "modified_by", - "type_info": "Int8" + "name": "certificate", + "type_info": "Text" }, { "ordinal": 10, - "name": "certificate", + "name": "modified_by", "type_info": "Text" }, { @@ -80,8 +80,8 @@ true, true, false, - false, true, + false, false ] }, diff --git a/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json b/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json index 67badd0eca..ce8b4b42a0 100644 --- a/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json +++ b/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json @@ -61,7 +61,7 @@ { "ordinal": 11, "name": "modified_by", - "type_info": "Int8" + "type_info": "Text" } ], "parameters": { diff --git a/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json b/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json index 9f496db050..3c397182d9 100644 --- a/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json +++ b/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json @@ -66,7 +66,7 @@ { "ordinal": 12, "name": "modified_by", - "type_info": "Int8" + "type_info": "Text" } ], "parameters": { diff --git a/.sqlx/query-f22ab4ecf7a0146f3280290f55e9e989d80a8997c465cffadcc8dd6e587eb567.json b/.sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json similarity index 70% rename from .sqlx/query-f22ab4ecf7a0146f3280290f55e9e989d80a8997c465cffadcc8dd6e587eb567.json rename to .sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json index 1beb4d2bbb..6d9d8cf06d 100644 --- a/.sqlx/query-f22ab4ecf7a0146f3280290f55e9e989d80a8997c465cffadcc8dd6e587eb567.json +++ b/.sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "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 WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false 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", + "query": "SELECT gateway.*, CASE WHEN gateway.connected_at IS NULL THEN false WHEN gateway.disconnected_at IS NULL THEN true WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false END AS \"connected!\", wn.name AS location_name FROM gateway JOIN wireguard_network wn ON gateway.location_id = wn.id", "describe": { "columns": [ { @@ -60,31 +60,21 @@ }, { "ordinal": 11, - "name": "modified_by", - "type_info": "Int8" - }, - { - "ordinal": 12, "name": "enabled", "type_info": "Bool" }, { - "ordinal": 13, - "name": "modified_by_firstname", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "modified_by_lastname", + "ordinal": 12, + "name": "modified_by", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 13, "name": "connected!", "type_info": "Bool" }, { - "ordinal": 16, + "ordinal": 14, "name": "location_name", "type_info": "Text" } @@ -106,11 +96,9 @@ false, false, false, - false, - false, null, false ] }, - "hash": "f22ab4ecf7a0146f3280290f55e9e989d80a8997c465cffadcc8dd6e587eb567" + "hash": "d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1" } diff --git a/.sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json b/.sqlx/query-e9121dafec5752d2941fb9bccdd7d229298a3cc2fb01f837be197c40ca4744fa.json similarity index 52% rename from .sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json rename to .sqlx/query-e9121dafec5752d2941fb9bccdd7d229298a3cc2fb01f837be197c40ca4744fa.json index 934ebefa09..c890c7b6f7 100644 --- a/.sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json +++ b/.sqlx/query-e9121dafec5752d2941fb9bccdd7d229298a3cc2fb01f837be197c40ca4744fa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed, initial_setup_step \"initial_setup_step: InitialSetupStep\" FROM settings WHERE id = 1", + "query": "SELECT s.instance_name, s.main_logo_url, s.nav_logo_url, s.wireguard_enabled, s.webhooks_enabled, s.worker_enabled, s.openid_enabled, COALESCE(w.completed, TRUE) AS \"initial_setup_completed!\" FROM settings s LEFT JOIN wizard w ON TRUE WHERE s.id = 1 LIMIT 1", "describe": { "columns": [ { @@ -40,29 +40,8 @@ }, { "ordinal": 7, - "name": "initial_setup_completed", + "name": "initial_setup_completed!", "type_info": "Bool" - }, - { - "ordinal": 8, - "name": "initial_setup_step: InitialSetupStep", - "type_info": { - "Custom": { - "name": "initial_setup_step", - "kind": { - "Enum": [ - "welcome", - "admin_user", - "general_configuration", - "ca", - "ca_summary", - "edge_component", - "confirmation", - "finished" - ] - } - } - } } ], "parameters": { @@ -76,9 +55,8 @@ false, false, false, - false, - false + null ] }, - "hash": "6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5" + "hash": "e9121dafec5752d2941fb9bccdd7d229298a3cc2fb01f837be197c40ca4744fa" } diff --git a/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json b/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json index 3b54687be0..448d27be49 100644 --- a/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json +++ b/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json @@ -61,7 +61,7 @@ { "ordinal": 11, "name": "modified_by", - "type_info": "Int8" + "type_info": "Text" } ], "parameters": { diff --git a/Cargo.lock b/Cargo.lock index c0d7ee6267..d7c58bd541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1347,6 +1347,7 @@ dependencies = [ "secrecy", "serde", "serde_cbor_2", + "serde_json", "sqlx", "struct-patch", "thiserror 2.0.18", @@ -1609,14 +1610,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 b4287f01d0..5dd2dec097 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -9,7 +9,7 @@ use defguard_common::{ config::{Command, DefGuardConfig, SERVER_CONFIG}, db::{ init_db, - models::{Settings, settings::initialize_current_settings}, + models::{ActiveWizard, Settings, Wizard, settings::initialize_current_settings}, }, messages::peer_stats_update::PeerStatsUpdate, types::proxy::ProxyControlMessage, @@ -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::{ @@ -98,18 +98,26 @@ async fn main() -> Result<(), anyhow::Error> { 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 { + let has_auto_adopt_flags = config.adopt_edge.is_some() || config.adopt_gateway.is_some(); + let wizard = Wizard::init(&pool, has_auto_adopt_flags).await?; + + if !wizard.completed { + if wizard.active_wizard == ActiveWizard::AutoAdoption { + 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 { anyhow::bail!("Setup web server exited with error: {err}"); } - - settings = Settings::get_current_settings(); } + let settings = Settings::get_current_settings(); + config.initialize_post_settings(); SERVER_CONFIG diff --git a/crates/defguard_common/Cargo.toml b/crates/defguard_common/Cargo.toml index 37765cdac3..f90b8750a0 100644 --- a/crates/defguard_common/Cargo.toml +++ b/crates/defguard_common/Cargo.toml @@ -28,6 +28,7 @@ rsa.workspace = true secrecy.workspace = true serde.workspace = true serde_cbor.workspace = true +serde_json.workspace = true sqlx.workspace = true struct-patch.workspace = true thiserror.workspace = true diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 1423806ef3..db6052c533 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -191,6 +191,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 c0de1bee30..98293331c9 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -21,7 +21,7 @@ pub struct Gateway { pub version: Option, pub enabled: bool, pub modified_at: NaiveDateTime, - pub modified_by: Id, + pub modified_by: String, } impl Gateway { @@ -43,7 +43,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. @@ -65,7 +65,7 @@ impl Gateway { certificate_expiry: None, version: None, enabled: true, - modified_by, + modified_by: modified_by.into(), modified_at, } } diff --git a/crates/defguard_common/src/db/models/migration_wizard.rs b/crates/defguard_common/src/db/models/migration_wizard.rs new file mode 100644 index 0000000000..d51fd22b5c --- /dev/null +++ b/crates/defguard_common/src/db/models/migration_wizard.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Serialize, Deserialize, Debug, Default)] +pub enum MigrationWizardStep { + #[default] + Welcome, + GeneralConfiguration, + CertificateAuthority, + CertificateSummary, + EdgeComponent, + EdgeComponentAdaptation, + Confirmation, + LocationMigration, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MigrationWizardLocationState { + pub locations: Vec, + pub current_location: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrationWizardState { + pub location_state: Option, +} diff --git a/crates/defguard_common/src/db/models/mod.rs b/crates/defguard_common/src/db/models/mod.rs index e93d57d940..6608988ce2 100644 --- a/crates/defguard_common/src/db/models/mod.rs +++ b/crates/defguard_common/src/db/models/mod.rs @@ -7,6 +7,7 @@ pub mod error; pub mod gateway; pub mod group; pub mod mfa_info; +pub mod migration_wizard; pub mod oauth2authorizedapp; pub mod oauth2client; pub mod oauth2token; @@ -14,11 +15,13 @@ 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; pub mod webauthn; pub mod wireguard; +pub mod wizard; pub mod yubikey; pub use auth_code::AuthCode; @@ -35,4 +38,5 @@ pub use settings::{Settings, SettingsEssentials}; pub use user::{MFAMethod, User}; pub use webauthn::WebAuthn; pub use wireguard::{WireguardNetwork, WireguardNetworkError}; +pub use wizard::{ActiveWizard, InitialSetupState, Wizard}; pub use yubikey::YubiKey; diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index df84f2d61d..c2b359f8ea 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -24,7 +24,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 { @@ -48,7 +48,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(), @@ -60,7 +60,7 @@ impl Proxy { certificate_expiry: None, version: None, enabled: true, - modified_by, + modified_by: modified_by.into(), modified_at: Utc::now().naive_utc(), } } @@ -93,13 +93,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: String) -> sqlx::Result<()> { diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index ad132f5448..cb9ba7f6a9 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -83,6 +83,7 @@ impl LdapSyncStatus { } #[derive(Clone, Debug, Copy, Eq, PartialEq, Deserialize, Serialize, Default, Type, PartialOrd)] +#[serde(rename_all = "snake_case")] #[sqlx(type_name = "initial_setup_step", rename_all = "snake_case")] pub enum InitialSetupStep { #[default] @@ -166,14 +167,12 @@ pub struct Settings { pub ca_key_der: Option>, pub ca_cert_der: Option>, pub ca_expiry: Option, - // Initial setup, general settings - pub initial_setup_completed: bool, + // General settings pub defguard_url: String, pub default_admin_group_name: String, pub authentication_period_days: i32, pub mfa_code_timeout_seconds: i32, pub public_proxy_url: String, - pub initial_setup_step: InitialSetupStep, pub default_admin_id: Option, } @@ -253,7 +252,6 @@ impl fmt::Debug for Settings { &self.gateway_disconnect_notifications_reconnect_notification_enabled, ) .field("ca_expiry", &self.ca_expiry) - .field("initial_setup_completed", &self.initial_setup_completed) .field("defguard_url", &self.defguard_url) .field("default_admin_group_name", &self.default_admin_group_name) .field( @@ -262,7 +260,6 @@ impl fmt::Debug for Settings { ) .field("mfa_code_timeout_seconds", &self.mfa_code_timeout_seconds) .field("public_proxy_url", &self.public_proxy_url) - .field("initial_setup_step", &self.initial_setup_step) .field("default_admin_id", &self.default_admin_id) .finish_non_exhaustive() } @@ -294,9 +291,9 @@ impl Settings { ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, \ ldap_user_rdn_attr, ldap_sync_groups, \ openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", \ - ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, defguard_url, \ + ca_key_der, ca_cert_der, ca_expiry, defguard_url, \ default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, \ - public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", \ + public_proxy_url, \ default_admin_id \ FROM \"settings\" WHERE id = 1", ) @@ -378,14 +375,12 @@ impl Settings { ca_key_der = $49, \ ca_cert_der = $50, \ ca_expiry = $51, \ - initial_setup_completed = $52, \ - defguard_url = $53, \ - default_admin_group_name = $54, \ - authentication_period_days = $55, \ - mfa_code_timeout_seconds = $56, \ - public_proxy_url = $57, \ - initial_setup_step = $58, \ - default_admin_id = $59 \ + defguard_url = $52, \ + default_admin_group_name = $53, \ + authentication_period_days = $54, \ + mfa_code_timeout_seconds = $55, \ + public_proxy_url = $56, \ + default_admin_id = $57 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -438,13 +433,11 @@ impl Settings { &self.ca_key_der as &Option>, &self.ca_cert_der as &Option>, &self.ca_expiry as &Option, - self.initial_setup_completed, self.defguard_url, self.default_admin_group_name, self.authentication_period_days, self.mfa_code_timeout_seconds, self.public_proxy_url, - &self.initial_setup_step as &InitialSetupStep, self.default_admin_id, ) .execute(executor) @@ -554,7 +547,6 @@ pub struct SettingsEssentials { pub worker_enabled: bool, pub openid_enabled: bool, pub initial_setup_completed: bool, - pub initial_setup_step: InitialSetupStep, } impl SettingsEssentials { @@ -564,32 +556,19 @@ impl SettingsEssentials { { query_as!( SettingsEssentials, - "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, \ - webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed, \ - initial_setup_step \"initial_setup_step: InitialSetupStep\" \ - FROM settings WHERE id = 1" + "SELECT s.instance_name, s.main_logo_url, s.nav_logo_url, s.wireguard_enabled, \ + s.webhooks_enabled, s.worker_enabled, s.openid_enabled, \ + COALESCE(w.completed, TRUE) AS \"initial_setup_completed!\" \ + FROM settings s \ + LEFT JOIN wizard w ON TRUE \ + WHERE s.id = 1 \ + LIMIT 1" ) .fetch_one(executor) .await } } -impl From for SettingsEssentials { - fn from(settings: Settings) -> Self { - SettingsEssentials { - webhooks_enabled: settings.webhooks_enabled, - wireguard_enabled: settings.wireguard_enabled, - worker_enabled: settings.worker_enabled, - openid_enabled: settings.openid_enabled, - nav_logo_url: settings.nav_logo_url, - instance_name: settings.instance_name, - main_logo_url: settings.main_logo_url, - initial_setup_completed: settings.initial_setup_completed, - initial_setup_step: settings.initial_setup_step, - } - } -} - pub mod defaults { pub static WELCOME_MESSAGE: &str = "Dear {{ first_name }} {{ last_name }}, 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..0f80270fc6 --- /dev/null +++ b/crates/defguard_common/src/db/models/setup_auto_adoption.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +#[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, +} 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/db/models/wizard.rs b/crates/defguard_common/src/db/models/wizard.rs new file mode 100644 index 0000000000..3e77fd37ab --- /dev/null +++ b/crates/defguard_common/src/db/models/wizard.rs @@ -0,0 +1,215 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sqlx::{PgExecutor, Type, types::Json}; +use tracing::info; + +use super::{ + migration_wizard::MigrationWizardState, + settings::InitialSetupStep, + setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, +}; + +/// Which wizard is currently active. Stored as a PostgreSQL enum column. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "active_wizard", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ActiveWizard { + None, + Initial, + AutoAdoption, + Migration, +} + +impl fmt::Display for ActiveWizard { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Initial => write!(f, "initial setup"), + Self::AutoAdoption => write!(f, "auto-adoption"), + Self::Migration => write!(f, "migration"), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InitialSetupState { + pub step: InitialSetupStep, +} + +/// The wizard singleton row. +/// +/// `active_wizard` and `completed` are regular DB columns. +/// Each wizard type has its own JSONB column for step-tracking state. +#[derive(Debug, Serialize)] +pub struct Wizard { + pub active_wizard: ActiveWizard, + pub completed: bool, + pub initial_setup_state: Option, + pub auto_adoption_state: Option, + pub migration_wizard_state: Option, +} + +/// Internal row type used for SQLx deserialization. +struct WizardRow { + active_wizard: ActiveWizard, + completed: bool, + initial_setup_state: Option>, + auto_adoption_state: Option>, + migration_wizard_state: Option>, +} + +impl Wizard { + pub async fn save<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + let initial_setup_state = self + .initial_setup_state + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + let auto_adoption_state = self + .auto_adoption_state + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + let migration_wizard_state = self + .migration_wizard_state + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + + sqlx::query( + "UPDATE wizard SET active_wizard = $1, completed = $2, \ + initial_setup_state = $3, auto_adoption_state = $4, \ + migration_wizard_state = $5 \ + WHERE is_singleton = TRUE", + ) + .bind(self.active_wizard) + .bind(self.completed) + .bind(initial_setup_state) + .bind(auto_adoption_state) + .bind(migration_wizard_state) + .execute(executor) + .await?; + + Ok(()) + } + + pub async fn get<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e>, + { + let row = sqlx::query_as!( + WizardRow, + "SELECT active_wizard AS \"active_wizard!: ActiveWizard\", \ + completed, \ + initial_setup_state AS \"initial_setup_state: Json\", \ + auto_adoption_state AS \"auto_adoption_state: Json\", \ + migration_wizard_state AS \"migration_wizard_state: Json\" \ + FROM wizard \ + WHERE is_singleton = TRUE \ + LIMIT 1" + ) + .fetch_one(executor) + .await?; + + Ok(Self { + active_wizard: row.active_wizard, + completed: row.completed, + initial_setup_state: row.initial_setup_state.map(|j| j.0), + auto_adoption_state: row.auto_adoption_state.map(|j| j.0), + migration_wizard_state: row.migration_wizard_state.map(|j| j.0), + }) + } + + /// Initialize the wizard at startup. + /// + /// The wizard row is always seeded by the migration. If `active_wizard` + /// is still `None` (i.e. no wizard has been activated yet), detect which + /// one should be active based on database state: + /// - Existing data (users/networks/devices) = `Migration` + /// - Fresh install with auto-adoption CLI flags = `AutoAdoption` + /// - Fresh install without flags = `Initial` + pub async fn init<'e, E>(executor: E, has_auto_adopt_flags: bool) -> Result + where + E: PgExecutor<'e> + Copy, + { + let mut wizard = Self::get(executor).await?; + + if wizard.completed { + info!("Wizard already completed, skipping initialization"); + return Ok(wizard); + } + + if wizard.active_wizard != ActiveWizard::None { + info!("Resuming {} wizard", wizard.active_wizard); + return Ok(wizard); + } + + 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?; + + let active_wizard; + + if has_auto_adopt_flags { + active_wizard = ActiveWizard::AutoAdoption; + } else if is_fresh_instance { + active_wizard = ActiveWizard::Initial; + } else { + active_wizard = ActiveWizard::Migration; + } + + wizard.active_wizard = active_wizard; + + info!("Starting {active_wizard} wizard"); + + wizard.save(executor).await?; + + Ok(wizard) + } + + #[must_use] + pub fn is_active(&self) -> bool { + self.active_wizard != ActiveWizard::None + } + + /// Returns `true` when the current wizard state requires authentication. + /// + /// During the Initial and AutoAdoption wizards, unauthenticated access is + /// allowed until the admin user has been created (i.e. the wizard step is + /// at or before `AdminUser`). All other wizard types (or steps past admin + /// creation) require a valid session. + #[must_use] + pub fn requires_auth(&self) -> bool { + match self.active_wizard { + ActiveWizard::Initial => { + let step = self + .initial_setup_state + .as_ref() + .map(|s| s.step) + .unwrap_or(InitialSetupStep::Welcome); + step > InitialSetupStep::AdminUser + } + ActiveWizard::AutoAdoption => { + let step = self + .auto_adoption_state + .as_ref() + .map(|s| s.step) + .unwrap_or(AutoAdoptionWizardStep::Welcome); + step > AutoAdoptionWizardStep::AdminUser + } + _ => true, + } + } +} diff --git a/crates/defguard_common/src/types/proxy.rs b/crates/defguard_common/src/types/proxy.rs index 4b39980ce4..070e0de0dc 100644 --- a/crates/defguard_common/src/types/proxy.rs +++ b/crates/defguard_common/src/types/proxy.rs @@ -24,7 +24,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/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index 56631cd9d4..e9ee894fc3 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -17,8 +17,8 @@ use defguard_common::db::{ OAuth2Token, Session, SessionState, Settings, group::{Group, Permission}, oauth2client::OAuth2Client, - settings::InitialSetupStep, user::User, + wizard::Wizard, }, }; use sqlx::PgPool; @@ -219,16 +219,21 @@ where type Rejection = WebError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let settings = Settings::get_current_settings(); - if !settings.initial_setup_completed { + let pool = extract_pool(parts, state).await?; + let wizard = Wizard::get(&pool).await.map_err(|err| { + error!("Failed to fetch wizard state: {err}"); + WebError::DbError("Failed to fetch wizard state".into()) + })?; + if !wizard.completed { // Allow unauthenticated access only up to the admin creation step. - if settings.initial_setup_step <= InitialSetupStep::AdminUser { + if !wizard.requires_auth() { return Ok(Self {}); } let session_info = SessionInfo::from_request_parts(parts, state).await?; if !session_info.user.is_active { return Err(WebError::Forbidden("user is disabled".into())); } + let settings = Settings::get_current_settings(); if let Some(default_admin_id) = settings.default_admin_id { if session_info.user.id == default_admin_id { return Ok(Self {}); diff --git a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs index 19f4d68add..278fab120f 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs @@ -7,6 +7,7 @@ use defguard_proto::enterprise::firewall::{ use rand::thread_rng; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use super::{create_acl_rule, create_test_users_and_devices, set_test_license_business}; use crate::enterprise::{ db::models::acl::{ AclAlias, AclAliasDestinationRange, AclRule, AclRuleDestinationRange, AliasKind, RuleState, @@ -14,8 +15,6 @@ use crate::enterprise::{ firewall::{process_destination_addrs, try_get_location_firewall_config}, }; -use super::{create_acl_rule, create_test_users_and_devices, set_test_license_business}; - #[test] fn test_process_destination_addrs_v4() { // Test data with mixed IPv4 and IPv6 networks diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index d3afa735bc..468050a138 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -25,7 +25,6 @@ pub struct AppInfo { smtp_enabled: bool, ldap_info: LdapInfo, external_openid_enabled: bool, - initial_setup_completed: bool, } pub(crate) async fn get_app_info( @@ -47,7 +46,6 @@ pub(crate) async fn get_app_info( ad: settings.ldap_uses_ad, }, external_openid_enabled, - initial_setup_completed: settings.initial_setup_completed, }; Ok(ApiResponse::json(res, StatusCode::OK)) diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 070331c853..7c31e848bf 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -15,7 +15,8 @@ use defguard_common::{ Settings, gateway::Gateway, proxy::Proxy, - settings::{InitialSetupStep, update_current_settings}, + settings::InitialSetupStep, + wizard::{InitialSetupState, Wizard}, }, }, types::proxy::ProxyControlMessage, @@ -574,7 +575,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); @@ -609,14 +610,25 @@ pub async fn setup_proxy_tls_stream( debug!("Edge setup completed successfully"); - let mut settings = Settings::get_current_settings(); - if !settings.initial_setup_completed { - settings.initial_setup_step = InitialSetupStep::Confirmation; - if let Err(err) = update_current_settings(&pool, settings).await { - yield Ok(flow.error(&format!("Failed to update setup step in settings: {err}"))); - return; + { + match Wizard::get(&pool).await { + Ok(mut wizard) => { + if !wizard.completed { + wizard.initial_setup_state = Some(InitialSetupState { + step: InitialSetupStep::Confirmation, + }); + if let Err(err) = wizard.save(&pool).await { + yield Ok(flow.error(&format!("Failed to update setup step in wizard: {err}"))); + return; + } + debug!("Initial setup step advanced to 'Confirmation'"); + } + } + Err(err) => { + yield Ok(flow.error(&format!("Failed to fetch wizard state: {err}"))); + return; + } } - debug!("Initial setup step advanced to 'Finished'"); } // Step 7: Done @@ -1010,7 +1022,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 828c88d199..a36d142f61 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -33,9 +33,7 @@ pub struct GatewayInfo { pub version: Option, pub enabled: bool, 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, } @@ -44,8 +42,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 \ @@ -54,7 +50,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) @@ -65,8 +60,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 \ @@ -74,7 +67,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/mod.rs b/crates/defguard_core/src/handlers/mod.rs index b530532ab3..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; @@ -50,6 +51,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/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index d0c3a4af35..907e60ff01 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -123,7 +123,7 @@ pub(crate) async fn update_proxy( proxy.name = data.name; proxy.enabled = data.enabled; - 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_core/src/handlers/session_info.rs b/crates/defguard_core/src/handlers/session_info.rs new file mode 100644 index 0000000000..65361742e6 --- /dev/null +++ b/crates/defguard_core/src/handlers/session_info.rs @@ -0,0 +1,68 @@ +use axum::{extract::State, http::StatusCode}; +use defguard_common::db::models::{User, Wizard}; +use serde::Serialize; + +use super::{ApiResponse, ApiResult}; +use crate::{appstate::AppState, auth::SessionExtractor, 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 wizard = Wizard::get(pool).await?; + + let Ok(SessionExtractor(session)) = session else { + if wizard.is_active() { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + wizard_flags: Some(wizard), + }, + 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(wizard), + }, + StatusCode::OK, + )) +} diff --git a/crates/defguard_core/src/handlers/wizard.rs b/crates/defguard_core/src/handlers/wizard.rs new file mode 100644 index 0000000000..e75eabb537 --- /dev/null +++ b/crates/defguard_core/src/handlers/wizard.rs @@ -0,0 +1,41 @@ +use axum::{Json, extract::State, http::StatusCode}; +use defguard_common::db::models::{migration_wizard::MigrationWizardState, wizard::Wizard}; +use serde_json::json; + +use super::{ApiResponse, ApiResult}; +use crate::{appstate::AppState, auth::AdminRole}; + +pub(crate) async fn get_wizard_flags( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let wizard = Wizard::get(&appstate.pool).await?; + + Ok(ApiResponse::json(wizard, StatusCode::OK)) +} + +pub(crate) async fn get_migration_wizard_state( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let wizard = Wizard::get(&appstate.pool).await?; + + Ok(ApiResponse::new( + json!({ + "migration_state": wizard.migration_wizard_state + }), + StatusCode::OK, + )) +} + +pub(crate) async fn update_migration_wizard_state( + _role: AdminRole, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + let mut wizard = Wizard::get(&appstate.pool).await?; + wizard.migration_wizard_state = Some(data); + wizard.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 083e88e9ec..b4d6cc997e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -43,12 +43,14 @@ 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, }, 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; @@ -237,9 +239,15 @@ 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)) + .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)) @@ -695,7 +703,6 @@ pub async fn init_dev_env(config: &DefGuardConfig) { 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().expect("Failed to get CA expiry")); - settings.initial_setup_completed = true; // This should possibly be initialized somehow differently in the future since we are deprecating the enrollment URL env var. settings.public_proxy_url = config.enrollment_url.to_string(); settings.defguard_url = config.url.to_string(); @@ -703,6 +710,27 @@ pub async fn init_dev_env(config: &DefGuardConfig) { .await .expect("Failed to update settings"); + // Mark wizard as completed for dev environment + use defguard_common::db::models::{ + settings::InitialSetupStep, + wizard::{ActiveWizard, InitialSetupState, Wizard}, + }; + let wizard = Wizard { + active_wizard: ActiveWizard::None, + completed: true, + initial_setup_state: Some(InitialSetupState { + step: InitialSetupStep::Finished, + }), + auto_adoption_state: None, + migration_wizard_state: None, + }; + // Ensure wizard is initialized, then overwrite with completed state + let _ = Wizard::init(&pool, false).await; + wizard + .save(&pool) + .await + .expect("Failed to save wizard state for dev env"); + let mut transaction = pool .begin() .await diff --git a/crates/defguard_core/tests/integration/api/gateway.rs b/crates/defguard_core/tests/integration/api/gateway.rs index e65b21ef86..6d4c10f879 100644 --- a/crates/defguard_core/tests/integration/api/gateway.rs +++ b/crates/defguard_core/tests/integration/api/gateway.rs @@ -22,11 +22,11 @@ async fn test_gateway_crud(_: PgPoolOptions, options: PgConnectOptions) { client.drain_all_events(); client.drain_all_events(); - let gateway_1 = Gateway::new(network.id, "gateway1", "127.0.0.1", 50051, 1) + let gateway_1 = Gateway::new(network.id, "gateway1", "127.0.0.1", 50051, "admin") .save(&client_state.pool) .await .unwrap(); - let gateway_2 = Gateway::new(network.id, "gateway2", "1.2.3.1", 55555, 1) + let gateway_2 = Gateway::new(network.id, "gateway2", "1.2.3.1", 55555, "admin") .save(&client_state.pool) .await .unwrap(); @@ -91,7 +91,7 @@ async fn test_gateway_endpoints_require_admin(_: PgPoolOptions, options: PgConne let response = make_network(&client, "network").await; let network: WireguardNetwork = response.json().await; - let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, 1) + let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, "admin") .save(&client_state.pool) .await .unwrap(); @@ -137,7 +137,7 @@ async fn test_gateway_update_rejects_unknown_fields(_: PgPoolOptions, options: P let response = make_network(&client, "network").await; let network: WireguardNetwork = response.json().await; - let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, 1) + let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, "admin") .save(&client_state.pool) .await .unwrap(); diff --git a/crates/defguard_core/tests/integration/api/location_stats.rs b/crates/defguard_core/tests/integration/api/location_stats.rs index 7b087763ed..b63ace24d8 100644 --- a/crates/defguard_core/tests/integration/api/location_stats.rs +++ b/crates/defguard_core/tests/integration/api/location_stats.rs @@ -64,7 +64,7 @@ async fn test_location_connected_devices_stats(_: PgPoolOptions, options: PgConn let response = make_network(&client, "network").await; let network: WireguardNetwork = response.json().await; - let gateway = Gateway::new(network.id, "gateway", "localhost", 50051, 1) + let gateway = Gateway::new(network.id, "gateway", "localhost", 50051, "admin") .save(&client_state.pool) .await .unwrap(); diff --git a/crates/defguard_core/tests/integration/api/proxy.rs b/crates/defguard_core/tests/integration/api/proxy.rs index 8e71c75096..93fe6fbea8 100644 --- a/crates/defguard_core/tests/integration/api/proxy.rs +++ b/crates/defguard_core/tests/integration/api/proxy.rs @@ -17,7 +17,7 @@ async fn test_proxy_details(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); // Create new proxy. - let proxy = Proxy::new("test", "localhost", 50051, 1) + let proxy = Proxy::new("test", "localhost", 50051, "admin") .save(&pool) .await .unwrap(); @@ -47,7 +47,7 @@ async fn test_proxy_update(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); // Create new proxy. - let mut proxy = Proxy::new("test", "localhost", 50051, 1) + let mut proxy = Proxy::new("test", "localhost", 50051, "DefGuard Administrator") .save(&pool) .await .unwrap(); @@ -98,7 +98,7 @@ async fn test_delete_proxy(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); // Create new proxy. - let proxy = Proxy::new("test", "localhost", 50051, 1) + let proxy = Proxy::new("test", "localhost", 50051, "admin") .save(&pool) .await .unwrap(); diff --git a/crates/defguard_session_manager/tests/common/mod.rs b/crates/defguard_session_manager/tests/common/mod.rs index b5ae4a65d3..f727b8d11c 100644 --- a/crates/defguard_session_manager/tests/common/mod.rs +++ b/crates/defguard_session_manager/tests/common/mod.rs @@ -1,13 +1,17 @@ use std::net::{IpAddr, Ipv4Addr}; -use defguard_common::db::Id; -use defguard_common::db::models::gateway::Gateway; -use defguard_common::db::models::{ - Device, DeviceType, User, WireguardNetwork, - device::WireguardNetworkDevice, - wireguard::{LocationMfaMode, ServiceLocationMode}, +use defguard_common::{ + db::{ + Id, + models::{ + Device, DeviceType, User, WireguardNetwork, + device::WireguardNetworkDevice, + gateway::Gateway, + wireguard::{LocationMfaMode, ServiceLocationMode}, + }, + }, + messages::peer_stats_update::PeerStatsUpdate, }; -use defguard_common::messages::peer_stats_update::PeerStatsUpdate; use defguard_session_manager::{SessionManager, events::SessionManagerEvent}; use ipnetwork::IpNetwork; use tokio::sync::{broadcast, mpsc}; @@ -106,7 +110,7 @@ pub(crate) async fn attach_device_to_network(pool: &sqlx::PgPool, network_id: Id pub(crate) async fn create_gateway( pool: &sqlx::PgPool, network_id: Id, - modified_by: Id, + modified_by: String, ) -> Gateway { Gateway::new( network_id, diff --git a/crates/defguard_session_manager/tests/session_manager/event_flow.rs b/crates/defguard_session_manager/tests/session_manager/event_flow.rs index 9dac431166..8ffe26d73c 100644 --- a/crates/defguard_session_manager/tests/session_manager/event_flow.rs +++ b/crates/defguard_session_manager/tests/session_manager/event_flow.rs @@ -1,8 +1,7 @@ use std::net::SocketAddr; use chrono::{TimeDelta, Utc}; -use defguard_common::db::setup_pool; -use defguard_common::messages::peer_stats_update::PeerStatsUpdate; +use defguard_common::{db::setup_pool, messages::peer_stats_update::PeerStatsUpdate}; use defguard_session_manager::{ SESSION_UPDATE_INTERVAL, events::SessionManagerEventType, run_session_manager_iteration, }; @@ -21,7 +20,7 @@ async fn test_session_manager_emits_connected_event(_: PgPoolOptions, options: P let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_network(&pool, network.id, device.id).await; - let gateway = create_gateway(&pool, network.id, user.id).await; + let gateway = create_gateway(&pool, network.id, user.fullname()).await; let mut harness = SessionManagerHarness::new(pool); diff --git a/crates/defguard_session_manager/tests/session_manager/sessions.rs b/crates/defguard_session_manager/tests/session_manager/sessions.rs index e9892a76c5..8d05bc51fe 100644 --- a/crates/defguard_session_manager/tests/session_manager/sessions.rs +++ b/crates/defguard_session_manager/tests/session_manager/sessions.rs @@ -1,7 +1,11 @@ use chrono::{TimeDelta, Utc}; -use defguard_common::db::models::vpn_client_session::{VpnClientSession, VpnClientSessionState}; -use defguard_common::db::setup_pool; -use defguard_common::messages::peer_stats_update::PeerStatsUpdate; +use defguard_common::{ + db::{ + models::vpn_client_session::{VpnClientSession, VpnClientSessionState}, + setup_pool, + }, + messages::peer_stats_update::PeerStatsUpdate, +}; use defguard_session_manager::{SESSION_UPDATE_INTERVAL, run_session_manager_iteration}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::time::{Duration, interval}; @@ -18,7 +22,7 @@ async fn test_session_manager_creates_active_session(_: PgPoolOptions, options: let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_network(&pool, network.id, device.id).await; - let gateway = create_gateway(&pool, network.id, user.id).await; + let gateway = create_gateway(&pool, network.id, user.fullname()).await; let mut harness = SessionManagerHarness::new(pool.clone()); diff --git a/crates/defguard_session_manager/tests/session_manager/stats.rs b/crates/defguard_session_manager/tests/session_manager/stats.rs index 7844ccbb95..542cf5467f 100644 --- a/crates/defguard_session_manager/tests/session_manager/stats.rs +++ b/crates/defguard_session_manager/tests/session_manager/stats.rs @@ -1,9 +1,10 @@ use std::net::SocketAddr; use chrono::{TimeDelta, Utc}; -use defguard_common::db::models::vpn_session_stats::VpnSessionStats; -use defguard_common::db::setup_pool; -use defguard_common::messages::peer_stats_update::PeerStatsUpdate; +use defguard_common::{ + db::{models::vpn_session_stats::VpnSessionStats, setup_pool}, + messages::peer_stats_update::PeerStatsUpdate, +}; use defguard_session_manager::{SESSION_UPDATE_INTERVAL, run_session_manager_iteration}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::time::{Duration, interval}; @@ -20,7 +21,7 @@ async fn test_session_manager_updates_stats(_: PgPoolOptions, options: PgConnect let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_network(&pool, network.id, device.id).await; - let gateway = create_gateway(&pool, network.id, user.id).await; + let gateway = create_gateway(&pool, network.id, user.fullname()).await; let mut harness = SessionManagerHarness::new(pool.clone()); 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..8f53cdcafb --- /dev/null +++ b/crates/defguard_setup/src/auto_adoption.rs @@ -0,0 +1,867 @@ +use std::time::Duration; + +use anyhow::Context; +use defguard_certs::{CertificateAuthority, CertificateInfo, Csr, PemLabel, der_to_pem}; +use defguard_common::{ + VERSION, + auth::claims::{Claims, ClaimsType}, + config::DefGuardConfig, + db::models::{ + Settings, WireguardNetwork, + gateway::Gateway, + proxy::Proxy, + settings::update_current_settings, + setup_auto_adoption::{AutoAdoptionComponentResult, 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, error, 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; +const GATEWAY_NAME: &str = "Gateway"; +const PROXY_NAME: &str = "Edge"; +const KEEPALIVE_INTERVAL_SECONDS: Duration = Duration::from_secs(5); + +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")?, + )) +} + +#[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, +) -> (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(KEEPALIVE_INTERVAL_SECONDS) + .tcp_keepalive(Some(KEEPALIVE_INTERVAL_SECONDS)) + .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 { + error!("Failed to extract hostname from proxy URL"); + 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) => { + error!("Failed to get CSR from proxy: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let csr = match Csr::from_der(&csr_response.der_data) { + Ok(csr) => csr, + Err(err) => { + error!("Failed to parse CSR: {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) => { + error!("Failed to sign CSR: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + }; + + if let Err(err) = client + .send_cert(ProxyDerPayload { + der_data: cert.der().to_vec(), + }) + .await + { + error!("Failed to send certificate to proxy: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + + let cert_info = match CertificateInfo::from_der(cert.der()) { + Ok(info) => info, + Err(err) => { + error!("Failed to parse certificate info: {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, +) -> (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(KEEPALIVE_INTERVAL_SECONDS) + .tcp_keepalive(Some(KEEPALIVE_INTERVAL_SECONDS)) + .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) => { + error!("Failed to get CSR from gateway: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + }; + + let csr = match Csr::from_der(&csr_response.der_data) { + Ok(csr) => csr, + Err(err) => { + error!("Failed to parse CSR: {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) => { + error!("Failed to sign CSR: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + }; + + if let Err(err) = client + .send_cert(GatewayDerPayload { + der_data: cert.der().to_vec(), + }) + .await + { + error!("Failed to send certificate to gateway: {err}"); + return adoption_failure_with_logs(&mut log_rx); + } + + let cert_info = match CertificateInfo::from_der(cert.der()) { + Ok(info) => info, + Err(err) => { + error!("Failed to parse certificate info: {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, +) -> Result<(), anyhow::Error> { + let (host, port) = parse_host_port(endpoint)?; + + let (status, logs, cert_info) = match component { + SetupAutoAdoptionComponent::Edge => run_edge_adoption_attempt(pool, &host, port).await, + SetupAutoAdoptionComponent::Gateway => run_gateway_adoption_attempt(&host, port).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, GATEWAY_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, PROXY_NAME, cert_info).await { + warn!( + "Edge adoption TLS handshake succeeded but failed to persist \ + proxy record: {err}" + ); + } + } + } + } + } + + let mut wizard = defguard_common::db::models::wizard::Wizard::get(pool) + .await + .context("Failed to load wizard state")?; + + wizard + .auto_adoption_state + .get_or_insert_with(Default::default) + .adoption_result + .insert( + component, + AutoAdoptionComponentResult { + success: status, + logs: logs.clone(), + updated_at: chrono::Utc::now().naive_utc(), + }, + ); + wizard.save(pool).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 = defguard_common::db::models::wizard::Wizard::get(pool) + .await + .context("Failed to load wizard state")?; + + let auto_state = wizard.auto_adoption_state.as_ref(); + let edge_already_succeeded = auto_state + .and_then(|s| s.adoption_result.get(&SetupAutoAdoptionComponent::Edge)) + .is_some_and(|result| result.success); + let gateway_already_succeeded = auto_state + .and_then(|s| s.adoption_result.get(&SetupAutoAdoptionComponent::Gateway)) + .is_some_and(|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) + .await + { + wizard + .auto_adoption_state + .get_or_insert_with(Default::default) + .adoption_result + .insert( + SetupAutoAdoptionComponent::Edge, + AutoAdoptionComponentResult { + success: false, + logs: vec![format!("Startup auto-adoption failed: {err}")], + updated_at: chrono::Utc::now().naive_utc(), + }, + ); + wizard.save(pool).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) + .await + { + wizard + .auto_adoption_state + .get_or_insert_with(Default::default) + .adoption_result + .insert( + SetupAutoAdoptionComponent::Gateway, + AutoAdoptionComponentResult { + success: false, + logs: vec![format!("Startup auto-adoption failed: {err}")], + updated_at: chrono::Utc::now().naive_utc(), + }, + ); + wizard.save(pool).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..cf47a5e836 --- /dev/null +++ b/crates/defguard_setup/src/handlers/auto_wizard.rs @@ -0,0 +1,212 @@ +use axum::{Extension, Json}; +use defguard_common::{ + db::models::{ + WireguardNetwork, + settings::update_current_settings, + setup_auto_adoption::AutoAdoptionWizardStep, + wireguard::LocationMfaMode, + wizard::{ActiveWizard, Wizard}, + }, + 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 wizard = Wizard::get(pool).await?; + Ok(wizard.active_wizard == ActiveWizard::AutoAdoption) +} + +pub(crate) async fn advance_auto_wizard_to_step( + pool: &PgPool, + step: AutoAdoptionWizardStep, +) -> Result<(), WebError> { + let mut wizard = Wizard::get(pool).await?; + let auto_state = wizard + .auto_adoption_state + .get_or_insert_with(Default::default); + if auto_state.step < step { + auto_state.step = step; + wizard.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", + auto_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 wizard = Wizard::get(&pool).await?; + Ok(ApiResponse::new( + json!(wizard.auto_adoption_state), + StatusCode::OK, + )) +} diff --git a/crates/defguard_setup/src/handlers.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs similarity index 76% rename from crates/defguard_setup/src/handlers.rs rename to crates/defguard_setup/src/handlers/initial_wizard.rs index 7bdd883cf5..5312f920cf 100644 --- a/crates/defguard_setup/src/handlers.rs +++ b/crates/defguard_setup/src/handlers/initial_wizard.rs @@ -15,6 +15,8 @@ use defguard_common::db::models::{ Session, SessionState, Settings, User, group::Group, settings::{InitialSetupStep, update_current_settings}, + setup_auto_adoption::AutoAdoptionWizardStep, + wizard::{ActiveWizard, InitialSetupState, Wizard}, }; use defguard_core::{ auth::{ @@ -32,28 +34,57 @@ use sqlx::PgPool; use tokio::sync::oneshot; use tracing::{debug, info}; -async fn advance_setup_to_step(pool: &PgPool, step: InitialSetupStep) -> Result<(), WebError> { - let mut settings = Settings::get_current_settings(); +use crate::handlers::auto_wizard::{advance_auto_wizard_to_step, is_auto_wizard_active}; + +async fn advance_initial_wizard_to_step( + pool: &PgPool, + step: InitialSetupStep, +) -> Result<(), WebError> { + let mut wizard = Wizard::get(pool).await?; // Don't try to advance if setup is already completed - if settings.initial_setup_completed { + if wizard.completed { debug!("Not advancing setup step as initial setup is already completed"); return Ok(()); } - if settings.initial_setup_step < step { - settings.initial_setup_step = step; - update_current_settings(pool, settings).await?; + let current_step = wizard + .initial_setup_state + .as_ref() + .map(|s| s.step) + .unwrap_or(InitialSetupStep::Welcome); + if current_step < step { + wizard.initial_setup_state = Some(InitialSetupState { step }); + wizard.save(pool).await?; info!("Advanced initial wizard setup to step {:?}", step); } else { debug!( - "Not advancing initial wizard setup step from {:?} to {:?} as it is not a forward step", - settings.initial_setup_step, step + "Not advancing initial wizard setup step from {current_step:?} to {step:?} as it is not a forward step" ); } 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_initial_wizard_to_step(pool, initial_step).await?; + } + + Ok(()) +} + #[derive(Deserialize, Serialize, Debug)] pub struct CreateAdmin { first_name: String, @@ -61,6 +92,8 @@ pub struct CreateAdmin { username: String, email: String, password: String, + #[serde(default)] + automatically_assign_group: bool, } #[derive(Deserialize, Serialize, Debug)] @@ -76,7 +109,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 +136,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 +178,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))) } @@ -130,12 +196,13 @@ pub async fn setup_login( Extension(failed_logins): Extension>>, Json(login): Json, ) -> Result<(CookieJar, ApiResponse), WebError> { - let settings = Settings::get_current_settings(); - if settings.initial_setup_completed { + let wizard = Wizard::get(&pool).await?; + if wizard.completed { return Err(WebError::Forbidden( "Initial setup already completed".to_string(), )); } + let settings = Settings::get_current_settings(); let default_admin_id = settings .default_admin_id .ok_or_else(|| WebError::Forbidden("Default admin user not set".into()))?; @@ -181,13 +248,14 @@ pub async fn setup_login( Ok((cookies, ApiResponse::with_status(StatusCode::OK))) } -pub async fn setup_session(session: SessionInfo) -> ApiResult { - let settings = Settings::get_current_settings(); - if settings.initial_setup_completed { +pub async fn setup_session(session: SessionInfo, Extension(pool): Extension) -> ApiResult { + let wizard = Wizard::get(&pool).await?; + if wizard.completed { return Err(WebError::Forbidden( "Initial setup already completed".to_string(), )); } + let settings = Settings::get_current_settings(); let default_admin_id = settings .default_admin_id .ok_or_else(|| WebError::Forbidden("Default admin user not set".into()))?; @@ -272,7 +340,7 @@ pub async fn set_general_config( info!("Initial general configuration applied"); - advance_setup_to_step(&pool, InitialSetupStep::Ca).await?; + advance_initial_wizard_to_step(&pool, InitialSetupStep::Ca).await?; Ok(ApiResponse::with_status(StatusCode::CREATED)) } @@ -311,7 +379,7 @@ pub async fn create_ca( info!("Certificate authority created and stored"); - advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + advance_initial_wizard_to_step(&pool, InitialSetupStep::CaSummary).await?; Ok(ApiResponse::with_status(StatusCode::CREATED)) } @@ -329,7 +397,7 @@ pub async fn get_ca(_: AdminOrSetupRole, Extension(pool): Extension) -> info.subject_common_name, valid_for_days ); - advance_setup_to_step(&pool, InitialSetupStep::EdgeComponent).await?; + advance_initial_wizard_to_step(&pool, InitialSetupStep::EdgeComponent).await?; Ok(ApiResponse::new( json!({ "ca_cert_pem": ca_pem, "subject_common_name": info.subject_common_name, "not_before": info.not_before, "not_after": info.not_after, "valid_for_days": valid_for_days }), @@ -363,7 +431,7 @@ pub async fn upload_ca( update_current_settings(&pool, settings).await?; - advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + advance_initial_wizard_to_step(&pool, InitialSetupStep::CaSummary).await?; info!("Certificate authority uploaded and stored"); @@ -376,10 +444,14 @@ pub async fn finish_setup( Extension(setup_shutdown_tx): Extension>>>>, ) -> ApiResult { info!("Finishing initial setup"); - let mut settings = Settings::get_current_settings(); - settings.initial_setup_step = InitialSetupStep::Finished; - settings.initial_setup_completed = true; - update_current_settings(&pool, settings).await?; + + let mut wizard = Wizard::get(&pool).await?; + wizard.initial_setup_state = Some(InitialSetupState { + step: InitialSetupStep::Finished, + }); + wizard.completed = true; + wizard.active_wizard = ActiveWizard::None; + wizard.save(&pool).await?; if let Some(tx) = setup_shutdown_tx .lock() .expect("Failed to lock setup shutdown sender") @@ -395,3 +467,11 @@ pub async fn finish_setup( Ok(ApiResponse::with_status(StatusCode::OK)) } + +/// Returns the full wizard state (active wizard, step states, etc.). +/// Used by the frontend to determine which wizard +/// to show and what step to resume. +pub async fn get_wizard_state(Extension(pool): Extension) -> ApiResult { + let wizard = Wizard::get(&pool).await?; + Ok(ApiResponse::json(wizard, StatusCode::OK)) +} diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs new file mode 100644 index 0000000000..71e7315709 --- /dev/null +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -0,0 +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 97bcacc111..880c780419 100644 --- a/crates/defguard_setup/src/lib.rs +++ b/crates/defguard_setup/src/lib.rs @@ -1,2 +1,3 @@ +pub mod auto_adoption; 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_server.rs similarity index 80% rename from crates/defguard_setup/src/setup.rs rename to crates/defguard_setup/src/setup_server.rs index 96091fb180..0c0919d253 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup_server.rs @@ -23,8 +23,11 @@ use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; use crate::handlers::{ - create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, setup_session, - upload_ca, + 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, get_wizard_state, set_general_config, + setup_login, setup_session, upload_ca, + }, }; pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { @@ -40,6 +43,7 @@ pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sen Router::<()>::new() .route("/health", get(health_check)) .route("/settings_essentials", get(get_settings_essentials)) + .route("/wizard", get(get_wizard_state)) .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) .nest( "/initial_setup", @@ -50,6 +54,11 @@ 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("/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)), ), ) diff --git a/crates/defguard_setup/tests/initial_setup.rs b/crates/defguard_setup/tests/initial_setup.rs index 7c0ee0c139..303fd7042f 100644 --- a/crates/defguard_setup/tests/initial_setup.rs +++ b/crates/defguard_setup/tests/initial_setup.rs @@ -12,11 +12,12 @@ use defguard_common::{ Session, Settings, User, group::Group, settings::{InitialSetupStep, initialize_current_settings}, + wizard::Wizard, }, setup_pool, }, }; -use defguard_setup::setup::build_setup_webapp; +use defguard_setup::setup_server::build_setup_webapp; use reqwest::{ Client, StatusCode, cookie::Jar, @@ -34,14 +35,15 @@ use tokio::{ const SESSION_COOKIE_NAME: &str = "defguard_session"; async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { - let settings = Settings::get(pool) - .await - .expect("Failed to fetch settings") - .expect("Settings not found"); - assert_eq!(settings.initial_setup_step, expected); - - let current_settings = Settings::get_current_settings(); - assert_eq!(current_settings.initial_setup_step, expected); + let wizard = Wizard::get(pool) + .await + .expect("Failed to fetch wizard state"); + let step = wizard + .initial_setup_state + .as_ref() + .map(|s| s.step) + .unwrap_or(InitialSetupStep::Welcome); + assert_eq!(step, expected); } struct TestClient { @@ -114,6 +116,9 @@ async fn test_create_admin(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -159,12 +164,65 @@ 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"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); + + 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; initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -212,6 +270,9 @@ async fn test_set_general_config(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -280,6 +341,9 @@ async fn test_create_ca(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -328,6 +392,9 @@ async fn test_upload_ca(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -374,6 +441,9 @@ async fn test_get_ca(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -425,6 +495,9 @@ async fn test_finish_setup(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (client, shutdown_rx) = make_setup_test_client(pool.clone()).await; @@ -449,12 +522,14 @@ async fn test_finish_setup(_: PgPoolOptions, options: PgConnectOptions) { .expect("Failed to finish setup"); assert_eq!(response.status(), StatusCode::OK); - let settings = Settings::get(&pool) + let wizard = Wizard::get(&pool) .await - .expect("Failed to fetch settings") - .expect("Settings not found"); - assert!(settings.initial_setup_completed); - assert_eq!(settings.initial_setup_step, InitialSetupStep::Finished); + .expect("Failed to fetch wizard state"); + assert!(wizard.completed); + assert_eq!( + wizard.initial_setup_state.as_ref().map(|s| s.step), + Some(InitialSetupStep::Finished) + ); assert_setup_step(&pool, InitialSetupStep::Finished).await; @@ -469,6 +544,9 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); let (setup_shutdown_tx, setup_shutdown_rx) = oneshot::channel::<()>(); let shutdown_notify = Arc::new(Notify::new()); @@ -572,7 +650,6 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .await .expect("Failed to fetch settings") .expect("Settings not found"); - assert!(settings.initial_setup_completed); assert_eq!(settings.defguard_url, "https://example.com"); assert_eq!(settings.default_admin_group_name, "admins"); assert_eq!(settings.authentication_period_days, 14); @@ -580,7 +657,15 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { assert!(settings.ca_cert_der.is_some()); assert!(settings.ca_key_der.is_some()); assert!(settings.ca_expiry.is_some()); - assert_eq!(settings.initial_setup_step, InitialSetupStep::Finished); + + let wizard = Wizard::get(&pool) + .await + .expect("Failed to fetch wizard state"); + assert!(wizard.completed); + assert_eq!( + wizard.initial_setup_state.as_ref().map(|s| s.step), + Some(InitialSetupStep::Finished) + ); let admin_group = Group::find_by_name(&pool, "admins") .await 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..f3d8b8826d --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql @@ -0,0 +1,25 @@ +-- Restore settings columns +ALTER TABLE settings + ADD COLUMN initial_setup_completed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN initial_setup_step initial_setup_step NOT NULL DEFAULT 'welcome'; + +-- Copy data back from wizard to settings +UPDATE settings SET + initial_setup_completed = w.completed, + initial_setup_step = COALESCE((w.initial_setup_state->>'step')::initial_setup_step, 'welcome') +FROM wizard w WHERE w.is_singleton = TRUE AND settings.id = 1; + +-- Reverse proxy modified_by: convert back to bigint FK +-- NOTE: name-to-id conversion is lossy; existing rows will have NULL modified_by. +ALTER TABLE proxy + ALTER COLUMN modified_by TYPE bigint USING NULL, + ADD CONSTRAINT proxy_modified_by_fkey FOREIGN KEY (modified_by) REFERENCES "user"(id); + +-- Reverse gateway modified_by: same as proxy +ALTER TABLE gateway + ALTER COLUMN modified_by TYPE bigint USING NULL, + ADD CONSTRAINT modified_by_fkey FOREIGN KEY (modified_by) REFERENCES "user"(id); + +-- Drop wizard table and enum +DROP TABLE wizard; +DROP TYPE active_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..fe55ac02b3 --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql @@ -0,0 +1,40 @@ +-- Create the active_wizard enum type +CREATE TYPE active_wizard AS ENUM ('none', 'initial', 'auto_adoption', 'migration'); + +-- Create wizard table: active_wizard and completed as columns, each wizard +-- type has its own JSONB column for step tracking state +CREATE TABLE wizard ( + is_singleton BOOLEAN NOT NULL DEFAULT TRUE PRIMARY KEY CHECK (is_singleton), + active_wizard active_wizard NOT NULL DEFAULT 'none', + completed BOOLEAN NOT NULL DEFAULT FALSE, + initial_setup_state JSONB, + auto_adoption_state JSONB, + migration_wizard_state JSONB +); + +-- Migrate initial_setup data from settings into wizard +INSERT INTO wizard (is_singleton, active_wizard, completed, initial_setup_state) +SELECT TRUE, 'none'::active_wizard, s.initial_setup_completed, + jsonb_build_object('step', s.initial_setup_step::text) +FROM settings s WHERE s.id = 1; + +-- Drop wizard-related columns from settings +ALTER TABLE settings + DROP COLUMN initial_setup_completed, + DROP COLUMN initial_setup_step; + +-- Proxy modified_by: convert from user id (bigint FK) to user name (text) +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; + +-- Gateway modified_by: convert from user id (bigint FK) to user name (text) +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 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/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index 10306548f9..b848082dc4 100644 --- a/web/messages/en/initial_wizard.json +++ b/web/messages/en/initial_wizard.json @@ -61,6 +61,76 @@ "initial_setup_general_config_error_public_proxy_url_invalid": "Public Proxy URL must be a valid URL", "initial_setup_general_config_error_public_proxy_url_required": "Public Proxy URL is required", + "initial_setup_auto_adoption_component_edge": "Edge", + "initial_setup_auto_adoption_component_gateway": "Gateway", + "initial_setup_auto_adoption_wizard_title": "Defguard configuration", + "initial_setup_auto_adoption_wizard_subtitle": "Complete the final three steps to fully configure Defguard.", + "initial_setup_auto_adoption_welcome_title": "Welcome to Defguard.", + "initial_setup_auto_adoption_welcome_subtitle_failed": "Unfortunately, the automated setup for some components did not complete successfully. Find detailed errors below.", + "initial_setup_auto_adoption_welcome_subtitle_success": "We have successfully configured all the necessary components (gateway and edge) using Docker for this instance. Now, we need to configure some general settings.", + "initial_setup_auto_adoption_success_guide_intro": "This guide will walk you through the process.", + "initial_setup_auto_adoption_success_guide_description": "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.", + "initial_setup_auto_adoption_success_start_button": "Start Defguard configuration", + "initial_setup_auto_adoption_failed_summary_title": "Error summary:", + "initial_setup_auto_adoption_failed_ca_success": "Certificate Authority setup successful.", + "initial_setup_auto_adoption_failed_component_success": "{component} setup successful", + "initial_setup_auto_adoption_failed_component_unsuccessful": "{component} setup unsuccessful", + "initial_setup_auto_adoption_failed_component_error_log_title": "{component} error log", + "initial_setup_auto_adoption_failed_support_business_prefix": "If you are a Business or Enterprise customer, please", + "initial_setup_auto_adoption_failed_support_business_link": "contact our support team", + "initial_setup_auto_adoption_failed_support_business_suffix": "and provide the logs you see in the error summary section above.", + "initial_setup_auto_adoption_failed_support_community_prefix": "If you are an Open Source or Free plan user, find support on", + "initial_setup_auto_adoption_failed_support_community_link": "Github Discussions.", + "initial_setup_auto_adoption_step_admin_user_label": "Create Admin User", + "initial_setup_auto_adoption_step_admin_user_description": "Manage core details and connection parameters for your VPN location.", + "initial_setup_auto_adoption_step_url_settings_label": "Internal and external URL settings", + "initial_setup_auto_adoption_step_url_settings_description": "Manage core details and connection parameters for your VPN location.", + "initial_setup_auto_adoption_step_vpn_settings_label": "VPN Public and Internal Settings", + "initial_setup_auto_adoption_step_vpn_settings_description": "Manage core details and connection parameters for your VPN location.", + "initial_setup_auto_adoption_step_mfa_setup_label": "Multi-Factor Authentication", + "initial_setup_auto_adoption_step_mfa_setup_description": "You can enable Multi-Factor Authentication (MFA) for your VPN.", + "initial_setup_auto_adoption_step_summary_label": "Summary", + "initial_setup_auto_adoption_step_summary_description": "Everything is set up and ready to go!", + "initial_setup_auto_adoption_mfa_option_disabled_title": "Do not enforce MFA", + "initial_setup_auto_adoption_mfa_option_internal_title": "Internal Defguard Multi-Factor Authentication", + "initial_setup_auto_adoption_mfa_option_internal_content": "Uses the MFA methods configured in your Defguard profile.", + "initial_setup_auto_adoption_mfa_option_internal_warning": "After completing the initial DefGuard setup, configure MFA in your profile to enable it.", + "initial_setup_auto_adoption_mfa_option_external_title": "External Identity Provider Authentication", + "initial_setup_auto_adoption_mfa_option_external_content": "Requires configuring an external identity provider in the settings, such as Google, Microsoft Entra ID, Okta, or JumpCloud.", + "initial_setup_auto_adoption_url_settings_defguard_description": "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.", + "initial_setup_auto_adoption_url_settings_public_proxy_description": "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.", + "initial_setup_auto_adoption_vpn_error_invalid_value": "Invalid value", + "initial_setup_auto_adoption_vpn_error_port_too_large": "Port number is too large", + "initial_setup_auto_adoption_vpn_intro": "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.", + "initial_setup_auto_adoption_vpn_label_public_ip": "Public IP", + "initial_setup_auto_adoption_vpn_label_wireguard_port": "WireGuard Port", + "initial_setup_auto_adoption_vpn_gateway_description": "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.", + "initial_setup_auto_adoption_vpn_label_gateway_address": "Gateway Address", + "initial_setup_auto_adoption_vpn_allowed_ips_description": "If you want your local networks to be accessible from VPN, list them in addresses/masks format below:", + "initial_setup_auto_adoption_vpn_label_allowed_ips": "Allowed IPs", + "initial_setup_auto_adoption_vpn_dns_description": "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).", + "initial_setup_auto_adoption_vpn_label_dns_server_ip": "DNS Server IP", + "initial_setup_auto_adoption_summary_error_settings_timeout": "Timed out waiting for settings essentials.", + "initial_setup_auto_adoption_summary_error_finish_console": "Failed to finish setup flow:", + "initial_setup_auto_adoption_summary_thank_you": "Thank you for choosing Defguard.", + "initial_setup_auto_adoption_summary_note": "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:", + "initial_setup_auto_adoption_summary_ports_http_https": "TCP ports 80 and 443", + "initial_setup_auto_adoption_summary_ports_wireguard": "UDP port {port}", + "initial_setup_auto_adoption_summary_encourage": "We would encourage you to:", + "initial_setup_auto_adoption_summary_docs_icon_alt": "Documentation Icon", + "initial_setup_auto_adoption_summary_docs_kicker": "Defguard insides", + "initial_setup_auto_adoption_summary_docs_title": "Get familiar with our security concepts and architecture", + "initial_setup_auto_adoption_summary_docs_button": "Learn more", + "initial_setup_auto_adoption_summary_community_icon_alt": "Community Icon", + "initial_setup_auto_adoption_summary_community_kicker": "Join our community", + "initial_setup_auto_adoption_summary_community_title": "Join our community and participate in discussion", + "initial_setup_auto_adoption_summary_community_button": "Join now", + "initial_setup_auto_adoption_summary_support_icon_alt": "Security Icon", + "initial_setup_auto_adoption_summary_support_kicker": "Support Us", + "initial_setup_auto_adoption_summary_support_title": "Star us on GitHub", + "initial_setup_auto_adoption_summary_support_button": "Go to GitHub", + "initial_setup_auto_adoption_summary_submit": "Go to Defguard", + "initial_setup_ca_validity_one_year": "1 year", "initial_setup_ca_validity_years": "{years} years", "initial_setup_ca_error_common_name_required": "Common name is required", diff --git a/web/src/pages/EdgesPage/EdgesTable.tsx b/web/src/pages/EdgesPage/EdgesTable.tsx index 5311f6688c..2517e89933 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}`; const getStatusBadge = (edge: EdgeInfo) => { if (!edge.enabled) { diff --git a/web/src/pages/EditEdgePage/EditEdgePage.tsx b/web/src/pages/EditEdgePage/EditEdgePage.tsx index 03dcb19c96..9e3a6f0a93 100644 --- a/web/src/pages/EditEdgePage/EditEdgePage.tsx +++ b/web/src/pages/EditEdgePage/EditEdgePage.tsx @@ -46,7 +46,7 @@ const formSchema = z.object({ connected_at: z.string().nullable(), disconnected_at: z.string().nullable(), modified_at: z.string(), - modified_by: z.number(), + modified_by: z.string(), version: z.string().nullable(), enabled: z.boolean(), }); diff --git a/web/src/pages/EditGatewayPage/EditGatewayPage.tsx b/web/src/pages/EditGatewayPage/EditGatewayPage.tsx index 584bf542d6..73c023bda5 100644 --- a/web/src/pages/EditGatewayPage/EditGatewayPage.tsx +++ b/web/src/pages/EditGatewayPage/EditGatewayPage.tsx @@ -47,7 +47,7 @@ const formSchema = z.object({ disconnected_at: z.string().nullable(), enabled: z.boolean(), modified_at: z.string(), - modified_by: z.number(), + modified_by: z.string(), version: z.string().nullable(), location_id: z.number(), }); diff --git a/web/src/pages/LocationsPage/components/GatewaysTable.tsx b/web/src/pages/LocationsPage/components/GatewaysTable.tsx index 325912af73..783d77472f 100644 --- a/web/src/pages/LocationsPage/components/GatewaysTable.tsx +++ b/web/src/pages/LocationsPage/components/GatewaysTable.tsx @@ -28,8 +28,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.enabled) { diff --git a/web/src/pages/SetupPage/assets/community.png b/web/src/pages/SetupPage/assets/community.png new file mode 100644 index 0000000000..2e732679dc Binary files /dev/null and b/web/src/pages/SetupPage/assets/community.png differ diff --git a/web/src/pages/SetupPage/assets/shield.png b/web/src/pages/SetupPage/assets/shield.png new file mode 100644 index 0000000000..71e088bb6a Binary files /dev/null and b/web/src/pages/SetupPage/assets/shield.png differ diff --git a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx new file mode 100644 index 0000000000..6debe3f863 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx @@ -0,0 +1,267 @@ +import { useQuery } from '@tanstack/react-query'; +import { type ReactNode, useMemo } from 'react'; +import { m } from '../../../paraglide/messages'; +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 m.initial_setup_auto_adoption_component_edge(); + if (component === 'gateway') return m.initial_setup_auto_adoption_component_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 ( +
+ +
+

+ {m.initial_setup_auto_adoption_failed_summary_title()} +

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

+ {m.initial_setup_auto_adoption_failed_support_business_prefix()}{' '} + + {m.initial_setup_auto_adoption_failed_support_business_link()} + {' '} + {m.initial_setup_auto_adoption_failed_support_business_suffix()} +

+
+
+ +

+ {m.initial_setup_auto_adoption_failed_support_community_prefix()}{' '} + + {m.initial_setup_auto_adoption_failed_support_community_link()} + +

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

{m.initial_setup_auto_adoption_success_guide_intro()}

+
+

{m.initial_setup_auto_adoption_success_guide_description()}

+ + +
+); + +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: m.initial_setup_auto_adoption_step_admin_user_label(), + description: m.initial_setup_auto_adoption_step_admin_user_description(), + }, + urlSettings: { + id: AutoAdoptionSetupStep.UrlSettings, + order: 2, + label: m.initial_setup_auto_adoption_step_url_settings_label(), + description: m.initial_setup_auto_adoption_step_url_settings_description(), + }, + vpnSettings: { + id: AutoAdoptionSetupStep.VpnSettings, + order: 3, + label: m.initial_setup_auto_adoption_step_vpn_settings_label(), + description: m.initial_setup_auto_adoption_step_vpn_settings_description(), + }, + mfaSetup: { + id: AutoAdoptionSetupStep.MfaSetup, + order: 4, + label: m.initial_setup_auto_adoption_step_mfa_setup_label(), + description: m.initial_setup_auto_adoption_step_mfa_setup_description(), + }, + summary: { + id: AutoAdoptionSetupStep.Summary, + order: 5, + label: m.initial_setup_auto_adoption_step_summary_label(), + description: m.initial_setup_auto_adoption_step_summary_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + adminUser: , + urlSettings: , + vpnSettings: , + mfaSetup: , + summary: , + }), + [], + ); + + const subtitle = hasFailedResult + ? m.initial_setup_auto_adoption_welcome_subtitle_failed() + : m.initial_setup_auto_adoption_welcome_subtitle_success(); + + 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..5769f5a637 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionMfaSetupStep.tsx @@ -0,0 +1,81 @@ +import { useMutation } from '@tanstack/react-query'; +import { m } from '../../../../paraglide/messages'; +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={m.initial_setup_auto_adoption_mfa_option_disabled_title()} + /> + + setMfaMode(LocationMfaMode.Internal)} + title={m.initial_setup_auto_adoption_mfa_option_internal_title()} + content={m.initial_setup_auto_adoption_mfa_option_internal_content()} + > + {mfaMode === LocationMfaMode.Internal && ( + <> + + + + )} + + + +
+ setActiveStep(AutoAdoptionSetupStep.VpnSettings), + }} + submitProps={{ + text: m.initial_setup_controls_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..e1c686558c --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionSummaryStep.tsx @@ -0,0 +1,179 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { m } from '../../../../paraglide/messages'; +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(m.initial_setup_auto_adoption_summary_error_settings_timeout()); + }; + + 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(m.initial_setup_auto_adoption_summary_error_finish_console(), error); + Snackbar.error(m.initial_setup_confirmation_error_finish_failed()); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +

{m.initial_setup_auto_adoption_summary_thank_you()}

+ +

{m.initial_setup_auto_adoption_summary_note()}

+ +
    +
  • {m.initial_setup_auto_adoption_summary_ports_http_https()}
  • +
  • + {m.initial_setup_auto_adoption_summary_ports_wireguard({ port: wireguardPort })} +
  • +
+ +

{m.initial_setup_auto_adoption_summary_encourage()}

+ +
+
+ {m.initial_setup_auto_adoption_summary_docs_icon_alt()} +
+
+

+ {m.initial_setup_auto_adoption_summary_docs_kicker()} +

+

+ {m.initial_setup_auto_adoption_summary_docs_title()} +

+
+
+
+ +
+ {m.initial_setup_auto_adoption_summary_community_icon_alt()} +
+
+

+ {m.initial_setup_auto_adoption_summary_community_kicker()} +

+

+ {m.initial_setup_auto_adoption_summary_community_title()} +

+
+
+
+ +
+ {m.initial_setup_auto_adoption_summary_support_icon_alt()} +
+
+

+ {m.initial_setup_auto_adoption_summary_support_kicker()} +

+

+ {m.initial_setup_auto_adoption_summary_support_title()} +

+
+
+
+
+ + +
+ ); +}; 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..d4033697c6 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -0,0 +1,126 @@ +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(); + }} + > + +

{m.initial_setup_auto_adoption_url_settings_defguard_description()}

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

{m.initial_setup_auto_adoption_url_settings_public_proxy_description()}

+ + + {(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..cc40629804 --- /dev/null +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionVpnSettingsStep.tsx @@ -0,0 +1,162 @@ +import { useMutation } from '@tanstack/react-query'; +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 { 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, m.form_error_required()) + .refine( + (value) => Validate.any(value, [Validate.IPv4, Validate.IPv6, Validate.Domain]), + m.initial_setup_auto_adoption_vpn_error_invalid_value(), + ), + vpn_wireguard_port: z + .number() + .min(1, m.form_error_required()) + .max(65535, m.initial_setup_auto_adoption_vpn_error_port_too_large()), + vpn_gateway_address: z + .string() + .trim() + .min(1, m.form_error_required()) + .refine( + (value) => Validate.any(value, [Validate.CIDRv4, Validate.CIDRv6], true), + m.initial_setup_auto_adoption_vpn_error_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(); + }} + > + +

{m.initial_setup_auto_adoption_vpn_intro()}

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

{m.initial_setup_auto_adoption_vpn_gateway_description()}

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

{m.initial_setup_auto_adoption_vpn_allowed_ips_description()}

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

{m.initial_setup_auto_adoption_vpn_dns_description()}

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