diff --git a/.sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json b/.sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.json similarity index 93% rename from .sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json rename to .sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.json index 4a1c1ceec..5bb4e8f78 100644 --- a/.sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json +++ b/.sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.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 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 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -86,10 +86,11 @@ } }, "Bytea", - "Bytea" + "Bytea", + "Timestamp" ] }, "nullable": [] }, - "hash": "bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a" + "hash": "34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075" } diff --git a/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json new file mode 100644 index 000000000..c6ce1e21b --- /dev/null +++ b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM proxy WHERE address = $1 AND port = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "public_address", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 6, + "name": "disconnected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 7, + "name": "has_certificate", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "certificate_expiry", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + true + ] + }, + "hash": "a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877" +} diff --git a/.sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json b/.sqlx/query-b48534fac2ea138d617f3653168c692e787fd1187d3dd2b8c128134c3d83cf92.json similarity index 69% rename from .sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json rename to .sqlx/query-b48534fac2ea138d617f3653168c692e787fd1187d3dd2b8c128134c3d83cf92.json index 1727fc222..15e64e3af 100644 --- a/.sqlx/query-57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a.json +++ b/.sqlx/query-b48534fac2ea138d617f3653168c692e787fd1187d3dd2b8c128134c3d83cf92.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\" FROM \"proxy\"", + "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\" FROM \"proxy\"", "describe": { "columns": [ { @@ -37,6 +37,16 @@ "ordinal": 6, "name": "disconnected_at", "type_info": "Timestamp" + }, + { + "ordinal": 7, + "name": "has_certificate", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "certificate_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -49,8 +59,10 @@ false, false, true, + true, + false, true ] }, - "hash": "57d87e1e6c73c6f630153227c3220a0bef9f3e0ec3ca5697130e3f506cfc2e0a" + "hash": "b48534fac2ea138d617f3653168c692e787fd1187d3dd2b8c128134c3d83cf92" } diff --git a/.sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json b/.sqlx/query-cef21a0fb292e51a461fc1bc44ec967087f477add7724e08b45ca60baabd2705.json similarity index 61% rename from .sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json rename to .sqlx/query-cef21a0fb292e51a461fc1bc44ec967087f477add7724e08b45ca60baabd2705.json index e73615683..e3f41ec87 100644 --- a/.sqlx/query-a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8.json +++ b/.sqlx/query-cef21a0fb292e51a461fc1bc44ec967087f477add7724e08b45ca60baabd2705.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"proxy\" (\"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\") VALUES ($1,$2,$3,$4,$5,$6) RETURNING id", + "query": "INSERT INTO \"proxy\" (\"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id", "describe": { "columns": [ { @@ -16,6 +16,8 @@ "Int4", "Text", "Timestamp", + "Timestamp", + "Bool", "Timestamp" ] }, @@ -23,5 +25,5 @@ false ] }, - "hash": "a3e132169196b632eca55d7b0421fd4acdbd1fec82a836c8e25162fe7d86b5b8" + "hash": "cef21a0fb292e51a461fc1bc44ec967087f477add7724e08b45ca60baabd2705" } diff --git a/.sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json b/.sqlx/query-d890aeacd36cbc31d5042a66cf8724f23803f5a9d400d81f39d8d6eacadb7565.json similarity index 65% rename from .sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json rename to .sqlx/query-d890aeacd36cbc31d5042a66cf8724f23803f5a9d400d81f39d8d6eacadb7565.json index 108c81e56..475daea81 100644 --- a/.sqlx/query-7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257.json +++ b/.sqlx/query-d890aeacd36cbc31d5042a66cf8724f23803f5a9d400d81f39d8d6eacadb7565.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"proxy\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"public_address\" = $5,\"connected_at\" = $6,\"disconnected_at\" = $7 WHERE id = $1", + "query": "UPDATE \"proxy\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"public_address\" = $5,\"connected_at\" = $6,\"disconnected_at\" = $7,\"has_certificate\" = $8,\"certificate_expiry\" = $9 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -11,10 +11,12 @@ "Int4", "Text", "Timestamp", + "Timestamp", + "Bool", "Timestamp" ] }, "nullable": [] }, - "hash": "7b09429adcf009bc19f24d95905e7e6bdba8432097e2e04ca06b036cacf38257" + "hash": "d890aeacd36cbc31d5042a66cf8724f23803f5a9d400d81f39d8d6eacadb7565" } diff --git a/.sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json b/.sqlx/query-ee66d9ce46f283c78402efcd27385327ebeecbefa97c0af2273f7147dab098be.json similarity index 69% rename from .sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json rename to .sqlx/query-ee66d9ce46f283c78402efcd27385327ebeecbefa97c0af2273f7147dab098be.json index 305f3f71f..5f78596bc 100644 --- a/.sqlx/query-acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04.json +++ b/.sqlx/query-ee66d9ce46f283c78402efcd27385327ebeecbefa97c0af2273f7147dab098be.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\" FROM \"proxy\" WHERE id = $1", + "query": "SELECT id, \"name\",\"address\",\"port\",\"public_address\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\" FROM \"proxy\" WHERE id = $1", "describe": { "columns": [ { @@ -37,6 +37,16 @@ "ordinal": 6, "name": "disconnected_at", "type_info": "Timestamp" + }, + { + "ordinal": 7, + "name": "has_certificate", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "certificate_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -51,8 +61,10 @@ false, false, true, + true, + false, true ] }, - "hash": "acfc047027db4967051af1f6404e6e503a54e4dd6144dacd824d5e8a829f2c04" + "hash": "ee66d9ce46f283c78402efcd27385327ebeecbefa97c0af2273f7147dab098be" } diff --git a/.sqlx/query-23d55f2b3d7f82c0bb6afdd4e88e64b9920b263b3d668704ecbe66d80aa7c586.json b/.sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.json similarity index 97% rename from .sqlx/query-23d55f2b3d7f82c0bb6afdd4e88e64b9920b263b3d668704ecbe66d80aa7c586.json rename to .sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.json index b6b62477c..7aac0a869 100644 --- a/.sqlx/query-23d55f2b3d7f82c0bb6afdd4e88e64b9920b263b3d668704ecbe66d80aa7c586.json +++ b/.sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.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 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 FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -284,6 +284,11 @@ "ordinal": 49, "name": "ca_cert_der", "type_info": "Bytea" + }, + { + "ordinal": 50, + "name": "ca_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -339,8 +344,9 @@ false, false, true, + true, true ] }, - "hash": "23d55f2b3d7f82c0bb6afdd4e88e64b9920b263b3d668704ecbe66d80aa7c586" + "hash": "f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec" } diff --git a/Cargo.lock b/Cargo.lock index 4bab99587..9184fa4c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,7 @@ name = "defguard_certs" version = "0.0.0" dependencies = [ "base64 0.22.1", + "chrono", "rcgen", "rustls-pki-types", "serde", @@ -1198,6 +1199,7 @@ version = "0.0.0" dependencies = [ "ammonia", "anyhow", + "async-stream", "axum", "axum-client-ip", "axum-extra", @@ -1212,6 +1214,7 @@ dependencies = [ "defguard_proto", "defguard_version", "defguard_web_ui", + "futures", "humantime", "hyper-util", "ipnetwork", diff --git a/Cargo.toml b/Cargo.toml index bfa936707..e2efd0769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ chrono = { version = "0.4", default-features = false, features = [ ] } claims = "0.8" clap = { version = "4.5", features = ["derive", "env"] } +futures = "0.3" humantime = "2.1" # match version used by sqlx ipnetwork = "0.20" diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index ac6e2f75c..244cb0bae 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -15,6 +15,7 @@ use defguard_common::{ }, }, messages::peer_stats_update::PeerStatsUpdate, + types::proxy::ProxyControlMessage, }; use defguard_core::{ auth::failed_login::FailedLoginMap, @@ -42,7 +43,10 @@ use defguard_mail::{Mail, run_mail_handler}; use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; use defguard_session_manager::run_session_manager; use secrecy::ExposeSecret; -use tokio::sync::{broadcast, mpsc::unbounded_channel}; +use tokio::sync::{ + broadcast, + mpsc::{channel, unbounded_channel}, +}; #[macro_use] extern crate tracing; @@ -133,7 +137,7 @@ async fn main() -> Result<(), anyhow::Error> { "No gRPC TLS certificate or key found in settings, generating self-signed certificate for gRPC server." ); - let ca = defguard_certs::CertificateAuthority::new()?; + let ca = defguard_certs::CertificateAuthority::new("Defguard", "", 10)?; let (cert_der, key_der) = (ca.cert_der().to_vec(), ca.key_pair_der().to_vec()); @@ -173,9 +177,14 @@ async fn main() -> Result<(), anyhow::Error> { } } + let (proxy_control_tx, proxy_control_rx) = channel::(100); let proxy_tx = ProxyTxSet::new(wireguard_tx.clone(), mail_tx.clone(), bidi_event_tx.clone()); - let proxy_manager = - ProxyManager::new(pool.clone(), proxy_tx, Arc::clone(&incompatible_components)); + let proxy_manager = ProxyManager::new( + pool.clone(), + proxy_tx, + Arc::clone(&incompatible_components), + proxy_control_rx, + ); // run services tokio::select! { @@ -205,6 +214,7 @@ async fn main() -> Result<(), anyhow::Error> { failed_logins, api_event_tx, incompatible_components, + proxy_control_tx ) => error!("Web server returned early: {res:?}"), res = run_mail_handler(mail_rx) => error!("Mail handler returned early: {res:?}"), res = run_periodic_peer_disconnect( diff --git a/crates/defguard_certs/Cargo.toml b/crates/defguard_certs/Cargo.toml index b769b0f9c..9207838d3 100644 --- a/crates/defguard_certs/Cargo.toml +++ b/crates/defguard_certs/Cargo.toml @@ -14,5 +14,6 @@ serde.workspace = true sqlx.workspace = true thiserror.workspace = true rustls-pki-types.workspace = true +chrono.workspace = true time = "0.3" x509-parser = "0.18" diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 09ca0ea91..1b46c25b6 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -1,15 +1,17 @@ +use std::str::FromStr; + use base64::{Engine, prelude::BASE64_STANDARD}; use rcgen::{ BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, - ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, SigningKey, + ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, SigningKey, string::Ia5String, }; use rustls_pki_types::{CertificateDer, CertificateSigningRequestDer, pem::PemObject}; +use sqlx::types::chrono::NaiveDateTime; use thiserror::Error; use time::{Duration, OffsetDateTime}; use x509_parser::parse_x509_certificate; const CA_NAME: &str = "Defguard CA"; -const CA_ORG: &str = "Defguard"; const NOT_BEFORE_OFFSET_SECS: Duration = Duration::minutes(5); const DEFAULT_CERT_VALIDITY_DAYS: i64 = 365; @@ -60,17 +62,27 @@ impl CertificateAuthority<'_> { Ok(CertificateAuthority { issuer, cert_der }) } - pub fn new() -> Result { + pub fn new( + common_name: &str, + email: &str, + valid_for_days: u32, + ) -> Result { let mut ca_params = CertificateParams::new(vec![CA_NAME.to_string()])?; // path length 0 to avoid issuing further CAs ca_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); ca_params .distinguished_name - .push(rcgen::DnType::OrganizationName, CA_ORG); + .push(rcgen::DnType::CommonName, common_name); + + let email_string = Ia5String::from_str(email)?; ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, CA_NAME); + .subject_alt_names + .push(rcgen::SanType::Rfc822Name(email_string)); + + let now = OffsetDateTime::now_utc(); + ca_params.not_before = now - NOT_BEFORE_OFFSET_SECS; + ca_params.not_after = now + Duration::days(i64::from(valid_for_days)); let ca_key_pair = KeyPair::generate()?; @@ -123,14 +135,25 @@ impl CertificateAuthority<'_> { pub fn key_pair_der(&self) -> &[u8] { self.issuer.key().serialized_der() } + + pub fn expiry(&self) -> Result { + get_certificate_expiry(&self.cert_der) + } } /// Extract the expiry date (not_after) from a certificate. -pub fn get_certificate_expiry(cert: &Certificate) -> Result { - let (_, parsed) = parse_x509_certificate(cert.der()) +pub fn get_certificate_expiry(cert_der: &[u8]) -> Result { + let (_, parsed) = parse_x509_certificate(cert_der) .map_err(|e| CertificateError::ParsingError(format!("Failed to parse certificate: {e}")))?; - Ok(parsed.tbs_certificate.validity.not_after.to_datetime()) + let expiry = parsed.tbs_certificate.validity.not_after.to_datetime(); + Ok(chrono::DateTime::from_timestamp(expiry.unix_timestamp(), 0) + .ok_or_else(|| { + CertificateError::ParsingError(format!( + "Failed to convert certificate expiry {expiry} to NaiveDateTime", + )) + })? + .naive_utc()) } pub struct Csr<'a> { @@ -235,7 +258,7 @@ mod tests { #[test] fn test_ca_creation() { - let ca = CertificateAuthority::new().unwrap(); + let ca = CertificateAuthority::new("Defguard CA", "email@email.com", 10).unwrap(); let key = ca.issuer.key(); let der = &ca.cert_der; let pem_string = cert_der_to_pem(der.as_ref()).unwrap(); @@ -246,7 +269,7 @@ mod tests { #[test] fn test_sign_csr() { - let ca = CertificateAuthority::new().unwrap(); + let ca = CertificateAuthority::new("Defguard CA", "email@email.com", 10).unwrap(); let cert_key_pair = generate_key_pair().unwrap(); let csr = Csr::new( &cert_key_pair, @@ -265,7 +288,7 @@ mod tests { fn test_sign_csr_with_validity() { use x509_parser::parse_x509_certificate; - let ca = CertificateAuthority::new().unwrap(); + let ca = CertificateAuthority::new("Defguard CA", "email@email.com", 10).unwrap(); let cert_key_pair = generate_key_pair().unwrap(); let csr = Csr::new( &cert_key_pair, @@ -309,4 +332,81 @@ mod tests { } } } + + #[test] + fn test_ca_validity() { + use x509_parser::parse_x509_certificate; + + let valid_days = 365; + let ca = CertificateAuthority::new("Test CA", "test@example.com", valid_days).unwrap(); + + let (_rem, parsed) = parse_x509_certificate(ca.cert_der()).unwrap(); + let validity = parsed.tbs_certificate.validity; + let not_before = validity.not_before.to_datetime(); + let not_after = validity.not_after.to_datetime(); + + let days = (not_after - not_before).whole_days(); + + assert!( + (valid_days as i64 - 1..=valid_days as i64 + 1).contains(&days), + "expected validity of {valid_days} days (±1), got {days} days" + ); + assert!( + not_after > not_before, + "not_after should be after not_before" + ); + } + + #[test] + fn test_ca_common_name() { + use x509_parser::parse_x509_certificate; + + let expected_cn = "My Custom CA"; + let ca = CertificateAuthority::new(expected_cn, "admin@example.com", 365).unwrap(); + + let (_rem, parsed) = parse_x509_certificate(ca.cert_der()).unwrap(); + let subject = &parsed.tbs_certificate.subject; + + let cn = subject + .iter_common_name() + .next() + .expect("Common Name not found") + .as_str() + .expect("Failed to parse CN as string"); + + assert_eq!( + cn, expected_cn, + "Common Name should match the provided value" + ); + } + + #[test] + fn test_ca_email() { + use x509_parser::parse_x509_certificate; + + let expected_email = "contact@defguard.net"; + let ca = CertificateAuthority::new("Test CA", expected_email, 365).unwrap(); + + let (_rem, parsed) = parse_x509_certificate(ca.cert_der()).unwrap(); + + let san_ext = parsed + .tbs_certificate + .extensions() + .iter() + .find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME) + .expect("Subject Alternative Name extension not found"); + + let san_value = san_ext.value; + + let email_bytes = expected_email.as_bytes(); + let email_found = san_value + .windows(email_bytes.len()) + .any(|window| window == email_bytes); + + assert!( + email_found, + "Email '{}' should be present in Subject Alternative Names", + expected_email + ); + } } diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index b95e126a1..ed79f2639 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -1,5 +1,6 @@ use chrono::NaiveDateTime; use model_derive::Model; +use sqlx::PgPool; use crate::db::{Id, NoId}; @@ -12,4 +13,39 @@ pub struct Proxy { pub public_address: String, pub connected_at: Option, pub disconnected_at: Option, + pub has_certificate: bool, + pub certificate_expiry: Option, +} + +impl Proxy { + pub fn new>(name: S, address: S, port: i32, public_address: S) -> Self { + Self { + id: NoId, + name: name.into(), + address: address.into(), + port, + public_address: public_address.into(), + connected_at: None, + disconnected_at: None, + has_certificate: false, + certificate_expiry: None, + } + } +} + +impl Proxy { + pub async fn find_by_address_port( + pool: &PgPool, + address: &str, + port: i32, + ) -> sqlx::Result> { + sqlx::query_as!( + Proxy, + "SELECT * FROM proxy WHERE address = $1 AND port = $2", + address, + port + ) + .fetch_optional(pool) + .await + } } diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 1801c6527..d2f0d5bcb 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt}; +use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, PgPool, Type, query, query_as}; use struct_patch::Patch; @@ -146,6 +147,7 @@ pub struct Settings { pub gateway_disconnect_notifications_reconnect_notification_enabled: bool, pub ca_key_der: Option>, pub ca_cert_der: Option>, + pub ca_expiry: Option, } // Implement manually to avoid exposing the license key. @@ -253,7 +255,7 @@ 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_key_der, ca_cert_der, ca_expiry \ FROM \"settings\" WHERE id = 1", ) .fetch_optional(executor) @@ -332,7 +334,8 @@ impl Settings { ldap_sync_groups = $47, \ openid_username_handling = $48, \ ca_key_der = $49, \ - ca_cert_der = $50 \ + ca_cert_der = $50, \ + ca_expiry = $51 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -384,6 +387,7 @@ impl Settings { &self.openid_username_handling as &OpenIdUsernameHandling, &self.ca_key_der as &Option>, &self.ca_cert_der as &Option>, + &self.ca_expiry as &Option ) .execute(executor) .await?; diff --git a/crates/defguard_common/src/types/mod.rs b/crates/defguard_common/src/types/mod.rs index ff2bbead0..e207a5c56 100644 --- a/crates/defguard_common/src/types/mod.rs +++ b/crates/defguard_common/src/types/mod.rs @@ -1,2 +1,3 @@ pub mod group_diff; +pub mod proxy; pub mod user_info; diff --git a/crates/defguard_common/src/types/proxy.rs b/crates/defguard_common/src/types/proxy.rs new file mode 100644 index 000000000..0f508210b --- /dev/null +++ b/crates/defguard_common/src/types/proxy.rs @@ -0,0 +1,7 @@ +use crate::db::Id; + +// Used by the proxy manager to control proxies (start/shutdown). +pub enum ProxyControlMessage { + StartConnection(Id), + ShutdownConnection(Id), +} diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index a38bb6e89..83a470645 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -26,6 +26,7 @@ base32 = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } +futures = { workspace = true } humantime = { workspace = true } # match version used by sqlx ipnetwork = { workspace = true } @@ -82,6 +83,7 @@ ammonia = "4.1" regex = "1.10" tower = "0.5" uaparser = "0.6" +async-stream = "0.3" [dev-dependencies] claims.workspace = true diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 10387ade9..27722d50a 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex, RwLock}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; -use defguard_common::config::server_config; +use defguard_common::{config::server_config, types::proxy::ProxyControlMessage}; use defguard_mail::Mail; use reqwest::Client; use secrecy::ExposeSecret; @@ -39,6 +39,7 @@ pub struct AppState { key: Key, pub event_tx: UnboundedSender, pub incompatible_components: Arc>, + pub proxy_control_tx: tokio::sync::mpsc::Sender, } impl AppState { @@ -116,6 +117,7 @@ impl AppState { failed_logins: Arc>, event_tx: UnboundedSender, incompatible_components: Arc>, + proxy_control_tx: tokio::sync::mpsc::Sender, ) -> Self { spawn(Self::handle_triggers(pool.clone(), rx)); @@ -146,6 +148,7 @@ impl AppState { key, event_tx, incompatible_components, + proxy_control_tx, } } } diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index d0dd38abf..bb2976c11 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -76,6 +76,9 @@ pub enum WebError { #[error("Activity log stream error: {0}")] #[schema(value_type=Object)] ActivityLogStreamError(#[from] ActivityLogStreamError), + #[error(transparent)] + #[schema(value_type=Object)] + CertificateError(#[from] defguard_certs::CertificateError), } impl From for WebError { diff --git a/crates/defguard_core/src/grpc/gateway/handler.rs b/crates/defguard_core/src/grpc/gateway/handler.rs index ae2f6e86d..d08be9f3f 100644 --- a/crates/defguard_core/src/grpc/gateway/handler.rs +++ b/crates/defguard_core/src/grpc/gateway/handler.rs @@ -401,19 +401,9 @@ impl GatewayHandler { client.send_cert(req).await?; - let expiry = defguard_certs::get_certificate_expiry(&cert)?; - self.gateway.has_certificate = true; - self.gateway.certificate_expiry = Some( - chrono::DateTime::from_timestamp(expiry.unix_timestamp(), 0) - .ok_or_else(|| { - GatewayError::ConversionError(format!( - "Failed to convert certificate expiry timestamp {} to DateTime", - expiry.unix_timestamp() - )) - })? - .naive_utc(), - ); + self.gateway.certificate_expiry = + Some(defguard_certs::get_certificate_expiry(cert.der())?); self.gateway.save(&self.pool).await?; } Err(err) => { diff --git a/crates/defguard_core/src/handlers/ca.rs b/crates/defguard_core/src/handlers/ca.rs new file mode 100644 index 000000000..7e82f1b6a --- /dev/null +++ b/crates/defguard_core/src/handlers/ca.rs @@ -0,0 +1,43 @@ +use axum::{Json, extract::State}; +use defguard_common::db::models::{Settings, settings::update_current_settings}; +use reqwest::StatusCode; +use serde_json::json; + +use crate::{ + appstate::AppState, + auth::AdminRole, + handlers::{ApiResponse, ApiResult}, +}; + +#[derive(Deserialize, Serialize, Debug)] +pub struct CreateCA { + common_name: String, + email: String, + validity_period_days: u32, +} + +pub async fn create_ca( + _role: AdminRole, + State(appstate): State, + Json(ca_info): Json, +) -> ApiResult { + let mut settings = Settings::get_current_settings(); + let ca = defguard_certs::CertificateAuthority::new( + &ca_info.common_name, + &ca_info.email, + ca_info.validity_period_days, + )?; + + let (cert_der, key_der) = (ca.cert_der().to_vec(), ca.key_pair_der().to_vec()); + + settings.ca_cert_der = Some(cert_der); + settings.ca_key_der = Some(key_der); + settings.ca_expiry = Some(ca.expiry()?); + + update_current_settings(&appstate.pool, settings).await?; + + Ok(ApiResponse { + json: json!({}), + status: StatusCode::CREATED, + }) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 1002d5b36..811ba9a6f 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -30,6 +30,7 @@ use crate::{ pub(crate) mod activity_log; pub(crate) mod app_info; pub(crate) mod auth; +pub mod ca; pub(crate) mod forward_auth; pub(crate) mod group; pub mod mail; @@ -37,6 +38,7 @@ pub mod network_devices; pub mod openid_clients; pub mod openid_flow; pub(crate) mod pagination; +pub(crate) mod proxy_setup; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; @@ -92,7 +94,8 @@ impl From for ApiResponse { | WebError::ClientIpError | WebError::FirewallError(_) | WebError::ApiEventChannelError(_) - | WebError::ActivityLogStreamError(_) => { + | WebError::ActivityLogStreamError(_) + | WebError::CertificateError(_) => { error!("{web_error}"); ApiResponse::new( json!({"msg": "Internal server error"}), diff --git a/crates/defguard_core/src/handlers/proxy_setup.rs b/crates/defguard_core/src/handlers/proxy_setup.rs new file mode 100644 index 000000000..e85807268 --- /dev/null +++ b/crates/defguard_core/src/handlers/proxy_setup.rs @@ -0,0 +1,541 @@ +use std::{convert::Infallible, time::Duration}; + +use axum::{ + extract::{Query, State}, + response::sse::{Event, KeepAlive, Sse}, +}; +use defguard_certs::{der_to_pem, get_certificate_expiry}; +use defguard_common::{ + VERSION, + auth::claims::Claims, + db::models::{Settings, proxy::Proxy}, + types::proxy::ProxyControlMessage, +}; +use defguard_proto::proxy::{CertificateInfo, DerPayload, proxy_setup_client::ProxySetupClient}; +use defguard_version::{Version, client::ClientVersionInterceptor}; +use futures::Stream; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use tokio_stream::StreamExt; +use tonic::{ + Request, Status, + service::Interceptor, + transport::{Certificate, ClientTlsConfig, Endpoint}, +}; + +use crate::{AppState, auth::AdminRole, version::MIN_PROXY_VERSION}; + +const TOKEN_CLIENT_ID: &str = "Defguard Core"; +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); + +/// Guard that aborts a tokio task when dropped +struct TaskGuard(tokio::task::JoinHandle<()>); + +impl Drop for TaskGuard { + fn drop(&mut self) { + self.0.abort(); + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProxySetupRequest { + pub ip_or_domain: String, + pub grpc_port: u16, + pub common_name: String, +} + +#[derive(Debug, Serialize, Copy, Clone)] +#[serde(tag = "step", content = "data")] +pub enum ProxySetupStep { + CheckingConfiguration, + CheckingAvailability, + CheckingVersion, + ObtainingCsr, + SigningCertificate, + ConfiguringTls, + Done, +} + +#[derive(Debug, Serialize)] +pub struct ProxySetupResponse { + #[serde(flatten)] + pub step: ProxySetupStep, + pub proxy_version: Option, + pub message: Option, + pub logs: Option>, + pub error: bool, +} + +#[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().unwrap(), + ); + Ok(request) + } +} + +fn fallback_message(err: &str, last_step: ProxySetupStep) -> String { + format!( + r#"{{"step":"{last_step:?}","message":"Failed to serialize error response: {err}","error":true}}"#, + ) +} + +fn error_message(message: &str, last_step: ProxySetupStep, logs: Option>) -> Event { + let response = ProxySetupResponse { + step: last_step, + proxy_version: None, + message: Some(message.to_string()), + logs, + error: true, + }; + + match serde_json::to_string(&response) { + Ok(body) => Event::default().data(body), + Err(e) => Event::default().data(fallback_message(&e.to_string(), last_step)), + } +} + +fn set_step_message(next_step: ProxySetupStep) -> Event { + let response = ProxySetupResponse { + step: next_step, + proxy_version: None, + message: None, + logs: None, + error: false, + }; + + match serde_json::to_string(&response) { + Ok(body) => Event::default().data(body), + Err(e) => Event::default().data(fallback_message(&e.to_string(), next_step)), + } +} + +struct SetupFlow { + last_step: ProxySetupStep, + log_rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl SetupFlow { + const fn new(log_rx: tokio::sync::mpsc::UnboundedReceiver) -> Self { + Self { + last_step: ProxySetupStep::CheckingConfiguration, + log_rx, + } + } + + fn step(&mut self, next_step: ProxySetupStep) -> Event { + self.last_step = next_step; + set_step_message(next_step) + } + + fn error(&mut self, message: &str) -> Event { + let mut collected_logs = Vec::new(); + while let Ok(log) = self.log_rx.try_recv() { + collected_logs.push(log); + } + let logs = if collected_logs.is_empty() { + None + } else { + Some(collected_logs) + }; + error_message(message, self.last_step, logs) + } +} + +/// This is the endpoint responsible for the whole edge proxy TLS setup flow. +/// It uses Server-Sent Events (SSE) to stream progress updates back to the frontend in real-time. +// This is a get request, since HTML's EventSource only supports GET +pub async fn setup_proxy_tls_stream( + _admin: AdminRole, + State(appstate): State, + Query(request): Query, +) -> Sse>> { + let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let stream = async_stream::stream! { + let mut flow = SetupFlow::new(log_rx); + + // Step 1: Check configuration + yield Ok( + flow.step(ProxySetupStep::CheckingConfiguration) + ); + + match Proxy::find_by_address_port(&appstate.pool, &request.ip_or_domain, i32::from(request.grpc_port)).await { + Ok(Some(proxy)) => { + yield Ok(flow.error(&format!("An edge Proxy with address {}:{} is already registered with name \"{}\".", request.ip_or_domain, request.grpc_port, proxy.name))); + return; + } + Ok(None) => { + debug!("Verified no existing proxy registration for {}:{}", request.ip_or_domain, request.grpc_port); + }, + Err(e) => { + yield Ok(flow.error(&format!("Failed to query existing proxy: {e}"))); + return; + } + } + + let url_str = format!("http://{}:{}", request.ip_or_domain, request.grpc_port); + + let url = match Url::parse(&url_str) { + Ok(u) => u, + Err(e) => { + yield Ok(flow.error(&format!("Invalid URL: {e}"))); + return; + } + }; + + debug!("Successfully validated proxy address: {}", url_str); + + let endpoint = match Endpoint::from_shared(url_str) { + Ok(e) => e, + Err(e) => { + yield Ok(flow.error(&format!("Failed to create endpoint: {e}"))); + return; + } + }; + + let endpoint = endpoint + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Some(Duration::from_secs(5))) + .keep_alive_while_idle(true); + + debug!("Connection endpoint configured with keep-alive settings"); + + let settings = Settings::get_current_settings(); + let Some(ca_cert_der) = settings.ca_cert_der else { + yield Ok(flow.error("CA certificate not found in settings")); + return; + }; + + let cert_pem = match der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate) { + Ok(pem) => pem, + Err(e) => { + yield Ok(flow.error(&format!("Failed to convert CA cert DER to PEM: {e}"))); + return; + } + }; + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(&cert_pem)); + + debug!("Loaded CA certificate for secure communication"); + + let endpoint = match endpoint.tls_config(tls) { + Ok(e) => e, + Err(e) => { + yield Ok(flow.error(&format!("Failed to configure TLS for endpoint: {e}"))); + return; + } + }; + + debug!("Prepared secure connection endpoint for proxy at {}:{}", request.ip_or_domain, request.grpc_port); + + let version = match Version::parse(VERSION) { + Ok(v) => v, + Err(e) => { + yield Ok(flow.error(&format!("Failed to parse version: {e}"))); + return; + } + }; + + // Step 2: Check availability + yield Ok( + flow.step(ProxySetupStep::CheckingAvailability) + ); + + + let version_clone = version.clone(); + + let token = match Claims::new( + defguard_common::auth::claims::ClaimsType::Gateway, + url.to_string(), + TOKEN_CLIENT_ID.to_string(), + u32::MAX.into(), + ) + .to_jwt() + { + Ok(token) => token, + Err(err) => { + yield Ok(flow.error(&format!("Failed to generate setup token: {err}"))); + return; + } + }; + + debug!("Generated secure setup token for proxy authentication"); + + let version_interceptor = ClientVersionInterceptor::new(version); + 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) + } + ); + + debug!("Initiating connection to edge proxy at {}:{}", request.ip_or_domain, request.grpc_port); + + let response_with_metadata = match tokio::time::timeout( + CONNECTION_TIMEOUT, + client.start(()) + ).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + match e.code() { + tonic::Code::Unavailable => { + let error_msg = e.to_string(); + if error_msg.contains("h2 protocol error") || error_msg.contains("http2 error") { + yield Ok(flow.error(&format!( + "Failed to connect to edge proxy at {}:{}: {}. This may indicate that the proxy is already configured with TLS. Please check if the proxy has already been set up.", + request.ip_or_domain, request.grpc_port, e + ))); + } else { + yield Ok(flow.error(&format!( + "Failed to connect to edge proxy at {}:{}. Please ensure the address and port are correct and that the edge component is running.", + request.ip_or_domain, request.grpc_port + ))); + } + } + _ => { + yield Ok(flow.error(&format!("Failed to connect to edge proxy: {e}"))); + } + } + return; + } + Err(_) => { + yield Ok(flow.error(&format!( + "Connection to edge proxy at {}:{} timed out after 10 seconds.", + request.ip_or_domain, request.grpc_port + ))); + return; + } + }; + + debug!("Successfully connected to edge proxy"); + + // Step 3: Check version + yield Ok( + flow.step(ProxySetupStep::CheckingVersion) + ); + + let proxy_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); + + debug!("Proxy metadata: {:?}", response_with_metadata.metadata()); + debug!("Proxy version: {:?}", proxy_version); + + if let Some(proxy_version) = proxy_version { + if proxy_version < MIN_PROXY_VERSION { + yield Ok(flow.error(&format!( + "Edge proxy version {proxy_version} is older than core version {version_clone}. Please update the edge component.", + ))); + return; + } + + debug!("Edge proxy version {} is compatible with core version {}", proxy_version, version_clone); + + let response = ProxySetupResponse { + step: ProxySetupStep::CheckingVersion, + proxy_version: Some(proxy_version.to_string()), + message: None, + logs: None, + error: false, + }; + + match serde_json::to_string(&response) { + Ok(body) => { + yield Ok( + Event::default().data(body) + ); + }, + Err(e) => { + yield Ok(flow.error(&format!("Failed to serialize version response: {e}"))); + return; + } + } + } else { + yield Ok(flow.error("Failed to determine edge proxy version")); + return; + } + + let mut response = response_with_metadata.into_inner(); + + let log_reader_task = tokio::spawn(async move { + while let Some(log_entry) = response.next().await { + match log_entry { + Ok(entry) => { + let level = entry.level + .strip_prefix("Level(") + .and_then(|s| s.strip_suffix(")")) + .unwrap_or(&entry.level) + .to_uppercase(); + + + let formatted = format!( + "{} {} {}: message={}", + entry.timestamp, + level, + entry.target, + entry.message + ); + if log_tx.send(formatted).is_err() { + break; + } + } + Err(e) => { + let _ = log_tx.send(format!("Error reading log: {e}")); + break; + } + } + } + }); + + // Create guard to ensure task is aborted on all exit paths + let _log_task_guard = TaskGuard(log_reader_task); + + // Step 4: Obtain CSR + yield Ok(flow.step(ProxySetupStep::ObtainingCsr)); + + let Some(hostname) = url.host_str() else { + yield Ok(flow.error("URL does not have a valid host")); + return; + }; + + let csr_response = match client + .get_csr(CertificateInfo { + cert_hostname: hostname.to_string(), + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + yield Ok(flow.error(&format!("Failed to obtain CSR: {e}"))); + return; + } + }; + + let csr = match defguard_certs::Csr::from_der(&csr_response.der_data) { + Ok(c) => c, + Err(e) => { + yield Ok(flow.error(&format!("Failed to parse CSR: {e}"))); + return; + } + }; + + debug!("Received certificate signing request from edge proxy for hostname: {}", hostname); + + // Step 5: Sign certificate + yield Ok(flow.step(ProxySetupStep::SigningCertificate)); + + let settings = Settings::get_current_settings(); + + let Some(ca_cert_der) = settings.ca_cert_der else { + yield Ok(flow.error("CA certificate not found in settings")); + return; + }; + + let Some(ca_key_pair) = settings.ca_key_der else { + yield Ok(flow.error("CA key pair not found in settings")); + return; + }; + + let ca = match defguard_certs::CertificateAuthority::from_cert_der_key_pair( + &ca_cert_der, + &ca_key_pair, + ) { + Ok(c) => c, + Err(e) => { + yield Ok(flow.error(&format!("Failed to create CA: {e}"))); + return; + } + }; + + debug!("Certificate authority loaded and ready to sign certificates"); + + let cert = match ca.sign_csr(&csr) { + Ok(c) => c, + Err(e) => { + yield Ok(flow.error(&format!("Failed to sign CSR: {e}"))); + return; + } + }; + + debug!("Successfully signed certificate for edge proxy"); + + // Step 6: Configure TLS + yield Ok(flow.step(ProxySetupStep::ConfiguringTls)); + + let response = DerPayload { + der_data: cert.der().to_vec(), + }; + + if let Err(e) = client.send_cert(response).await { + yield Ok(flow.error(&format!("Failed to send certificate: {e}"))); + return; + } + + debug!("Certificate successfully delivered to edge proxy"); + + let expiry = match get_certificate_expiry(cert.der()) { + Ok(dt) => { + dt + }, + Err(err) => { + yield Ok(flow.error(&format!("Failed to get certificate expiry: {err}"))); + return; + } + }; + + debug!("Certificate expiry date determined: {}", expiry); + + let mut proxy = Proxy::new( + &request.common_name, + &request.ip_or_domain, + i32::from(request.grpc_port), + &request.ip_or_domain, + ); + + proxy.has_certificate = true; + proxy.certificate_expiry = Some(expiry); + + + let proxy = match proxy.save(&appstate.pool).await { + Ok(p) => p, + Err(err) => { + yield Ok(flow.error(&format!("Failed to save proxy to database: {err}"))); + return; + } + }; + + debug!("Edge proxy '{}' registered successfully with ID: {}", request.common_name, proxy.id); + debug!("Establishing connection to newly configured edge proxy"); + if let Err(err) = appstate.proxy_control_tx.send(ProxyControlMessage::StartConnection(proxy.id)).await { + yield Ok(flow.error(&format!("Failed send message to connect to proxy after setup: {err}"))); + return; + } + + debug!("Edge proxy setup completed successfully"); + + // Step 7: Done + yield Ok(flow.step(ProxySetupStep::Done)); + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 803a600a4..7c7eff566 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -28,6 +28,7 @@ use defguard_common::{ }, }, }, + types::proxy::ProxyControlMessage, }; use defguard_mail::Mail; use defguard_version::server::DefguardVersionLayer; @@ -42,6 +43,7 @@ use handlers::{ find_available_ips, get_network_device, list_network_devices, modify_network_device, start_network_device_setup, start_network_device_setup_for_device, }, + proxy_setup::setup_proxy_tls_stream, ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, rename_authentication_key, @@ -112,6 +114,7 @@ use crate::{ totp_disable, totp_enable, totp_secret, webauthn_end, webauthn_finish, webauthn_init, webauthn_start, }, + ca::create_ca, forward_auth::forward_auth, group::{ add_group_member, create_group, delete_group, get_group, list_groups, modify_group, @@ -212,6 +215,7 @@ pub fn build_webapp( event_tx: UnboundedSender, version: Version, incompatible_components: Arc>, + proxy_control_tx: tokio::sync::mpsc::Sender, ) -> Router { let webapp: Router = Router::new() .route("/", get(index)) @@ -350,7 +354,11 @@ pub fn build_webapp( // ldap .route("/ldap/test", get(test_ldap_settings)) // activity log - .route("/activity_log", get(get_activity_log_events)), + .route("/activity_log", get(get_activity_log_events)) + // Certificate authority + .route("/ca", post(create_ca)) + // Proxy setup with SSE + .route("/proxy/setup/stream", get(setup_proxy_tls_stream)), ); // Enterprise features @@ -548,6 +556,7 @@ pub fn build_webapp( failed_logins, event_tx, incompatible_components, + proxy_control_tx, )) .layer( TraceLayer::new_for_http() @@ -575,6 +584,7 @@ pub async fn run_web_server( failed_logins: Arc>, event_tx: UnboundedSender, incompatible_components: Arc>, + proxy_control_tx: tokio::sync::mpsc::Sender, ) -> Result<(), anyhow::Error> { let webapp = build_webapp( webhook_tx, @@ -587,6 +597,7 @@ pub async fn run_web_server( event_tx, Version::parse(VERSION)?, incompatible_components, + proxy_control_tx, ); info!("Started web services"); let server_config = server_config(); diff --git a/crates/defguard_core/src/version.rs b/crates/defguard_core/src/version.rs index 6f37bd582..27c557aa3 100644 --- a/crates/defguard_core/src/version.rs +++ b/crates/defguard_core/src/version.rs @@ -9,7 +9,7 @@ use defguard_version::{ComponentInfo, Version, is_version_lower}; use serde::Serialize; use tonic::{Status, service::Interceptor}; -const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); +pub const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); pub const MIN_GATEWAY_VERSION: Version = Version::new(1, 6, 0); static OUTDATED_COMPONENT_LIFETIME: TimeDelta = TimeDelta::hours(1); diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 225eeaf1d..7447bf2d3 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -33,7 +33,7 @@ use tokio::{ net::TcpListener, sync::{ broadcast::{self, Receiver}, - mpsc::{UnboundedReceiver, unbounded_channel}, + mpsc::{UnboundedReceiver, channel, unbounded_channel}, }, }; @@ -117,6 +117,8 @@ pub(crate) async fn make_base_client( config.clone(), ); + let (proxy_control_tx, _proxy_control_rx) = channel(10); + // Uncomment this to enable tracing in tests. // It only works for running a single test, so leave it commented out for running all tests. // use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -139,6 +141,7 @@ pub(crate) async fn make_base_client( api_event_tx, Version::parse(VERSION).unwrap(), Arc::default(), + proxy_control_tx, ); ( diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index 6057dea7b..921930af9 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -14,6 +14,7 @@ use defguard_common::{ Id, models::{Settings, proxy::Proxy}, }, + types::proxy::ProxyControlMessage, }; use defguard_core::{ db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, @@ -34,9 +35,8 @@ use defguard_core::{ }; use defguard_mail::Mail; use defguard_proto::proxy::{ - AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, DerPayload, - InitialInfo, InitialSetupInfo, core_request, core_response, proxy_client::ProxyClient, - proxy_setup_client::ProxySetupClient, + AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, InitialInfo, + core_request, core_response, proxy_client::ProxyClient, }; use defguard_version::{ ComponentInfo, DefguardComponent, client::ClientVersionInterceptor, get_tracing_variables, @@ -48,9 +48,11 @@ use semver::Version; use sqlx::PgPool; use thiserror::Error; use tokio::{ + select, sync::{ + Mutex, broadcast::Sender, - mpsc::{self, UnboundedSender}, + mpsc::{self, Receiver, UnboundedSender}, }, task::JoinSet, time::sleep, @@ -70,11 +72,11 @@ pub(crate) mod password_reset; extern crate tracing; const TEN_SECS: Duration = Duration::from_secs(10); -const PROXY_AFTER_SETUP_CONNECT_DELAY: Duration = Duration::from_secs(1); static VERSION_ZERO: Version = Version::new(0, 0, 0); #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub(crate) enum Scheme { + #[allow(dead_code)] Http, Https, } @@ -182,6 +184,7 @@ pub struct ProxyManager { tx: ProxyTxSet, incompatible_components: Arc>, router: Arc>, + proxy_control: Receiver, } impl ProxyManager { @@ -189,12 +192,14 @@ impl ProxyManager { pool: PgPool, tx: ProxyTxSet, incompatible_components: Arc>, + proxy_control_rx: Receiver, ) -> Self { Self { pool, tx, incompatible_components, router: Arc::default(), + proxy_control: proxy_control_rx, } } @@ -202,18 +207,22 @@ impl ProxyManager { /// /// Each proxy runs in its own task and shares Core-side infrastructure /// such as routing state and compatibility tracking. - pub async fn run(self) -> Result<(), ProxyError> { + pub async fn run(mut self) -> Result<(), ProxyError> { debug!("ProxyManager starting"); // Retrieve proxies from DB. + let mut shutdown_channels = HashMap::new(); let mut proxies: Vec = Proxy::all(&self.pool) .await? .iter() .map(|proxy| { + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + shutdown_channels.insert(proxy.id, shutdown_tx); ProxyServer::from_proxy( proxy, self.pool.clone(), &self.tx, Arc::clone(&self.router), + Arc::new(Mutex::new(Some(shutdown_rx))), ) }) .collect::>()?; @@ -223,16 +232,17 @@ impl ProxyManager { if let Some(ref url) = server_config().proxy_url { debug!("Adding proxy from cli arg: {url}"); let url = Url::from_str(url)?; - let proxy = - ProxyServer::new(self.pool.clone(), url, &self.tx, Arc::clone(&self.router)); - proxies.push(proxy); - } - // TODO setup a channel to allow dynamic proxy connections - if proxies.is_empty() { - debug!("No proxies to connect to, waiting for changes"); - tokio::time::sleep(Duration::MAX).await; - return Ok(()); + let proxy = ProxyServer::new( + self.pool.clone(), + url, + &self.tx, + Arc::clone(&self.router), + // Currently we can't shutdown this proxy since it was started via CLI arguments (no ID in DB) + // This should be removed when we do a proper import of old proxies + Arc::new(Mutex::new(None)), + ); + proxies.push(proxy); } // Connect to all proxies. @@ -241,11 +251,57 @@ impl ProxyManager { debug!("Spawning proxy task for proxy {}", proxy.url); tasks.spawn(proxy.run(self.tx.clone(), self.incompatible_components.clone())); } - while let Some(result) = tasks.join_next().await { - match result { - Ok(Ok(())) => error!("Proxy task returned prematurely"), - Ok(Err(err)) => error!("Proxy task returned with error: {err}"), - Err(err) => error!("Proxy task execution failed: {err}"), + + loop { + select! { + result = tasks.join_next(), if !tasks.is_empty() => { + match result { + Some(Ok(Ok(()))) => error!("Proxy task returned prematurely"), + Some(Ok(Err(err))) => error!("Proxy task returned with error: {err}"), + Some(Err(err)) => error!("Proxy task execution failed: {err}"), + None => { + debug!("All proxy tasks completed"); + } + } + } + msg = self.proxy_control.recv() => { + match msg { + Some(ProxyControlMessage::StartConnection(id)) => { + debug!("Starting proxy with ID: {id}"); + if let Ok(Some(proxy_model)) = Proxy::find_by_id(&self.pool, id).await { + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + shutdown_channels.insert(id, shutdown_tx); + match ProxyServer::from_proxy( + &proxy_model, + self.pool.clone(), + &self.tx, + Arc::clone(&self.router), + Arc::new(Mutex::new(Some(shutdown_rx))), + ) { + Ok(proxy) => { + debug!("Spawning proxy task for proxy {}", proxy.url); + tasks.spawn(proxy.run(self.tx.clone(), self.incompatible_components.clone())); + } + Err(err) => error!("Failed to create proxy server: {err}"), + } + } else { + error!("Failed to find proxy with ID: {id}"); + } + } + Some(ProxyControlMessage::ShutdownConnection(id)) => { + debug!("Shutting down proxy with ID: {id}"); + if let Some(shutdown_tx) = shutdown_channels.remove(&id) { + let _ = shutdown_tx.send(()); + } else { + warn!("No shutdown channel found for proxy ID: {id}"); + } + } + None => { + debug!("Proxy control channel closed"); + break; + } + } + } } } Ok(()) @@ -276,6 +332,8 @@ impl ProxyTxSet { } } +type ShutdownReceiver = tokio::sync::oneshot::Receiver<()>; + /// Represents a single Core - Proxy connection. /// /// A `Proxy` is responsible for establishing and maintaining a gRPC @@ -291,10 +349,17 @@ struct ProxyServer { router: Arc>, /// Proxy server gRPC URL url: Url, + shutdown_signal: Arc>>, } impl ProxyServer { - pub fn new(pool: PgPool, url: Url, tx: &ProxyTxSet, router: Arc>) -> Self { + pub fn new( + pool: PgPool, + url: Url, + tx: &ProxyTxSet, + router: Arc>, + shutdown_signal: Arc>>, + ) -> Self { // Instantiate gRPC servers. let services = ProxyServices::new(&pool, tx); @@ -303,6 +368,7 @@ impl ProxyServer { services, router, url, + shutdown_signal, } } @@ -311,9 +377,10 @@ impl ProxyServer { pool: PgPool, tx: &ProxyTxSet, router: Arc>, + shutdown_signal: Arc>>, ) -> Result { let url = Url::from_str(&format!("http://{}:{}", proxy.address, proxy.port))?; - Ok(Self::new(pool, url, tx, router)) + Ok(Self::new(pool, url, tx, router, shutdown_signal)) } fn endpoint(&self, scheme: Scheme) -> Result { @@ -358,16 +425,6 @@ impl ProxyServer { incompatible_components: Arc>, ) -> Result<(), ProxyError> { loop { - // TODO: When we will have proxy table, we should first check in DB if we already configured - // this proxy, and only perform initial setup if not. - if let Err(err) = self.perform_initial_setup().await { - warn!( - "Failed to perform initial Proxy setup: {err}. Will try to connect anyway as proxy may be already setup." - ); - } else { - sleep(PROXY_AFTER_SETUP_CONNECT_DELAY).await; - } - let endpoint = self.endpoint(Scheme::Https)?; debug!("Connecting to proxy at {}", endpoint.uri()); @@ -438,65 +495,29 @@ impl ProxyServer { payload: Some(core_response::Payload::InitialInfo(initial_info)), }); - self.message_loop(tx, tx_set.wireguard.clone(), &mut resp_stream) - .await?; - } - } - - /// Attempt to perform an initial setup of the target proxy. - /// If the proxy doesn't have signed gRPC certificates by Core yet, - /// this step will perform the signing. Otherwise, the step will be skipped - /// by instantly sending the "Done" message by both parties. - pub async fn perform_initial_setup(&self) -> Result<(), ProxyError> { - let endpoint = self.endpoint(Scheme::Http)?; - - let interceptor = ClientVersionInterceptor::new(Version::parse(VERSION)?); - let mut client = ProxySetupClient::with_interceptor(endpoint.connect_lazy(), interceptor); - let Some(hostname) = self.url.host_str() else { - return Err(ProxyError::UrlError( - "Proxy URL missing hostname".to_string(), - )); - }; - - let csr = client - .start(InitialSetupInfo { - cert_hostname: hostname.to_string(), - }) - .await? - .into_inner(); - - let csr = defguard_certs::Csr::from_der(&csr.der_data)?; - - let settings = Settings::get_current_settings(); - - let ca_cert_der = settings.ca_cert_der.ok_or_else(|| { - ProxyError::MissingConfiguration( - "CA certificate DER not found in settings for proxy gRPC bidi stream".to_string(), - ) - })?; - let ca_key_pair = settings.ca_key_der.ok_or_else(|| { - ProxyError::MissingConfiguration( - "CA key pairs DER not found in settings for proxy gRPC bidi stream".to_string(), - ) - })?; - - let ca = defguard_certs::CertificateAuthority::from_cert_der_key_pair( - &ca_cert_der, - &ca_key_pair, - )?; - - match ca.sign_csr(&csr) { - Ok(cert) => { - let response = DerPayload { - der_data: cert.der().to_vec(), - }; - client.send_cert(response).await?; - info!( - "Signed CSR received from proxy during initial setup and sent back the certificate" - ); - } - Err(err) => { - error!("Failed to sign CSR: {err}"); + let shutdown_signal = self.shutdown_signal.lock().await.take(); + if let Some(shutdown_signal) = shutdown_signal { + select! { + res = self.message_loop(tx, tx_set.wireguard.clone(), &mut resp_stream) => { + if let Err(err) = res { + error!("Proxy message loop ended with error: {err}, reconnecting in {TEN_SECS:?}",); + } else { + info!("Proxy message loop ended, reconnecting in {TEN_SECS:?}"); + } + sleep(TEN_SECS).await; + } + res = shutdown_signal => { + if let Err(err) = res { + error!("An error occurred when trying to wait for a shutdown signal for Proxy: {err}. Reconnecting to: {}", endpoint.uri()); + } else { + info!("Shutdown signal received, stopping proxy connection to {}", endpoint.uri()); + } + break; + } + } + } else { + self.message_loop(tx, tx_set.wireguard.clone(), &mut resp_stream) + .await?; } } diff --git a/migrations/20260116095450_[2.0.0]_proxy_certificates.down.sql b/migrations/20260116095450_[2.0.0]_proxy_certificates.down.sql new file mode 100644 index 000000000..01200febf --- /dev/null +++ b/migrations/20260116095450_[2.0.0]_proxy_certificates.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE proxy DROP COLUMN has_certificate; +ALTER TABLE proxy DROP COLUMN certificate_expiry; +ALTER TABLE proxy DROP CONSTRAINT unique_address_port; +ALTER TABLE settings DROP COLUMN ca_expiry; diff --git a/migrations/20260116095450_[2.0.0]_proxy_certificates.up.sql b/migrations/20260116095450_[2.0.0]_proxy_certificates.up.sql new file mode 100644 index 000000000..de514147d --- /dev/null +++ b/migrations/20260116095450_[2.0.0]_proxy_certificates.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE proxy ADD COLUMN has_certificate BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE proxy ADD COLUMN certificate_expiry TIMESTAMP WITHOUT TIME ZONE NULL; +ALTER TABLE proxy ADD CONSTRAINT unique_address_port UNIQUE (address, port); +ALTER TABLE settings ADD COLUMN ca_expiry TIMESTAMP WITHOUT TIME ZONE NULL; diff --git a/proto b/proto index 906412eb5..413435816 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 906412eb50ac605f4904a355c2f325f3645c117e +Subproject commit 4134358160e4f819515c9a6e5c014434cfb46d74 diff --git a/web/messages/en/edge_wizard.json b/web/messages/en/edge_wizard.json new file mode 100644 index 000000000..411c240de --- /dev/null +++ b/web/messages/en/edge_wizard.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "edge_setup_confirmation_title": "Edge component configured successfully.", + "edge_setup_confirmation_subtitle": "Your VPN location is now fully configured.", + "edge_setup_add_multiple_edge_components_title": "Add multiple Edge components", + "edge_setup_add_multiple_edge_components_subtitle": "Each location can include multiple Edge components. You may add another component at this stage or return and do it later.", + "edge_setup_controls_add_another_edge_component": "Add another edge component", + "edge_setup_controls_go_to_edge_components": "Go to edge components", + "edge_setup_page_title": "Configure Edge component", + "edge_setup_page_subtitle": "To activate localization, make sure at least one Edge component is connected.", + "edge_setup_welcome_title": "Deploy your Edge component", + "edge_setup_welcome_subtitle": "Welcome to the Edge component Setup Wizard. This guide will help you deploy the Edge required for secure and reliable VPN communication.", + "edge_setup_welcome_deploy_title": "Deploy Edge component first", + "edge_setup_welcome_deploy_subtitle": "Make sure your Edge Component is deployed. If it's already in place, click the button bellow to configure it in the following steps — otherwise deploy it first and return to this wizard.", + "edge_setup_welcome_docs_text": "Before installation, we recommend reading our documentation to understand the system architecture and core components.", + "edge_setup_welcome_image_alt": "Welcome to Edge component setup wizard", + "edge_setup_controls_configure": "Configure Edge component", + "edge_setup_step_edge_component_label": "Configure edge component", + "edge_setup_step_edge_component_description": "Set up your VPN proxy quickly and ensure secure, optimized traffic flow for your users.", + "edge_setup_step_edge_adaptation_label": "Edge Component Adaptation", + "edge_setup_step_edge_adaptation_description": "Review the system's checks and see if any issues need attention before deployment.", + "edge_setup_step_confirmation_label": "Confirmation", + "edge_setup_step_confirmation_description": "Your configuration was successful. You're all set.", + "edge_setup_component_label_common_name": "Common Name", + "edge_setup_component_label_ip_or_domain": "IP or Domain", + "edge_setup_component_label_grpc_port": "gRPC Port", + "edge_setup_component_label_public_domain": "Public Domain", + "edge_setup_component_error_common_name_required": "Common Name is required", + "edge_setup_component_error_ip_or_domain_required": "IP or Domain is required", + "edge_setup_component_error_grpc_port_required": "gRPC Port is required", + "edge_setup_component_error_grpc_port_max": "gRPC Port must be less than 65536", + "edge_setup_component_error_public_domain_required": "Public Domain is required", + "edge_setup_component_controls_back": "Back", + "edge_setup_component_controls_submit": "Adopt Edge component", + "edge_setup_adaptation_checking_configuration": "Checking provided Edge component configuration", + "edge_setup_adaptation_checking_availability": "Checking if Edge is available at: {ip_or_domain}:{grpc_port}", + "edge_setup_adaptation_checking_version": "Checking Edge proxy version", + "edge_setup_adaptation_checking_version_with_value": "Checking Edge proxy version: {proxyVersion}", + "edge_setup_adaptation_obtaining_csr": "Obtaining Certificate Signing Request", + "edge_setup_adaptation_signing_certificate": "Signing Certificate", + "edge_setup_adaptation_configuring_tls": "Configuring TLS communication", + "edge_setup_adaptation_error_default": "An error occurred during setup.", + "edge_setup_adaptation_error_log_title": "Error log", + "edge_setup_adaptation_controls_retry": "Retry", + "edge_setup_adaptation_controls_back": "Back", + "edge_setup_adaptation_controls_continue": "Continue" +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index a0d9217b7..ca115a53c 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -18,6 +18,7 @@ "./messages/{locale}/groups.json", "./messages/{locale}/openid.json", "./messages/{locale}/activity.json", + "./messages/{locale}/edge_wizard.json", "./messages/{locale}/settings.json" ] } diff --git a/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx b/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx new file mode 100644 index 000000000..7e355393b --- /dev/null +++ b/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx @@ -0,0 +1,107 @@ +import './style.scss'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +import { ActionCard } from '../../shared/components/ActionCard/ActionCard'; +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 { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../shared/defguard-ui/types'; +import deployImage from './assets/deploy.svg'; +import welcomeImage from './assets/welcome_image.svg'; +import { SetupConfirmationStep } from './steps/SetupConfirmationStep'; +import { SetupEdgeAdaptationStep } from './steps/SetupEdgeAdaptationStep'; +import { SetupEdgeComponentStep } from './steps/SetupEdgeComponentStep'; +import { EdgeSetupStep, type EdgeSetupStepValue } from './types'; +import { useEdgeWizardStore } from './useEdgeWizardStore'; + +export const EdgeSetupPage = () => { + const activeStep = useEdgeWizardStore((s) => s.activeStep); + const showWelcome = useEdgeWizardStore((s) => s.showWelcome); + const setShowWelcome = useEdgeWizardStore((s) => s.setShowWelcome); + const navigate = useNavigate(); + + const stepsConfig = useMemo( + (): Record => ({ + edgeComponent: { + id: EdgeSetupStep.EdgeComponent, + order: 1, + label: m.edge_setup_step_edge_component_label(), + description: m.edge_setup_step_edge_component_description(), + }, + edgeAdaptation: { + id: EdgeSetupStep.EdgeAdaptation, + order: 2, + label: m.edge_setup_step_edge_adaptation_label(), + description: m.edge_setup_step_edge_adaptation_description(), + }, + confirmation: { + id: EdgeSetupStep.Confirmation, + order: 3, + label: m.edge_setup_step_confirmation_label(), + description: m.edge_setup_step_confirmation_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + edgeComponent: , + edgeAdaptation: , + confirmation: , + }), + [], + ); + + const WelcomePageContent = () => ( + <> + +
+ + + +
+ + ); + + return ( + { + useEdgeWizardStore.getState().reset(); + navigate({ + to: '/settings', + replace: true, + }); + }} + subtitle={m.edge_setup_page_subtitle()} + title={m.edge_setup_page_title()} + steps={stepsConfig} + id="setup-wizard" + showWelcome={showWelcome} + welcomePageConfig={{ + title: m.edge_setup_welcome_title(), + subtitle: m.edge_setup_welcome_subtitle(), + content: , + docsLink: 'https://docs.defguard.net/edge-component/deployment', + docsText: m.edge_setup_welcome_docs_text(), + media: {m.edge_setup_welcome_image_alt()}, + }} + > + {stepsComponents[activeStep]} + + ); +}; diff --git a/web/src/pages/EdgeSetupPage/assets/add_more.svg b/web/src/pages/EdgeSetupPage/assets/add_more.svg new file mode 100644 index 000000000..36badeb38 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/assets/add_more.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/EdgeSetupPage/assets/deploy.svg b/web/src/pages/EdgeSetupPage/assets/deploy.svg new file mode 100644 index 000000000..346353af4 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/assets/deploy.svg @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/EdgeSetupPage/assets/file_icon.png b/web/src/pages/EdgeSetupPage/assets/file_icon.png new file mode 100644 index 000000000..f0cafdc06 Binary files /dev/null and b/web/src/pages/EdgeSetupPage/assets/file_icon.png differ diff --git a/web/src/pages/EdgeSetupPage/assets/welcome_image.svg b/web/src/pages/EdgeSetupPage/assets/welcome_image.svg new file mode 100644 index 000000000..c265621aa --- /dev/null +++ b/web/src/pages/EdgeSetupPage/assets/welcome_image.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx new file mode 100644 index 000000000..0e976d4db --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from '@tanstack/react-router'; +import { m } from '../../../paraglide/messages'; +import { ActionCard } from '../../../shared/components/ActionCard/ActionCard'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import addMoreImage from '../assets/add_more.svg'; +import { useEdgeWizardStore } from '../useEdgeWizardStore'; + +export const SetupConfirmationStep = () => { + const navigate = useNavigate(); + + const handleBack = () => { + useEdgeWizardStore.getState().reset(); + }; + + const handleFinish = () => { + useEdgeWizardStore.getState().reset(); + navigate({ to: '/vpn-overview' }); + }; + + return ( + +

{m.edge_setup_confirmation_title()}

+ +

{m.edge_setup_confirmation_subtitle()}

+ + + +
+ ); +}; diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx new file mode 100644 index 000000000..408bab60f --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { m } from '../../../paraglide/messages'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { LoadingStep } from '../../../shared/components/LoadingStep/LoadingStep'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { CodeCard } from '../../../shared/defguard-ui/components/CodeCard/CodeCard'; +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 { EdgeSetupStep } from '../types'; +import { useEdgeWizardStore } from '../useEdgeWizardStore'; +import type { SetupEvent, SetupStep, SetupStepId } from './types'; +import { useSSEController } from './useSSEController'; + +export const SetupEdgeAdaptationStep = () => { + const setActiveStep = useEdgeWizardStore((s) => s.setActiveStep); + const edgeComponentWizardStore = useEdgeWizardStore((s) => s); + const edgeAdaptationState = useEdgeWizardStore((s) => s.edgeAdaptationState); + const setEdgeAdaptationState = useEdgeWizardStore((s) => s.setEdgeAdaptationState); + const resetEdgeAdaptationState = useEdgeWizardStore((s) => s.resetEdgeAdaptationState); + + const handleEvent = useCallback( + (event: SetupEvent) => { + setEdgeAdaptationState({ + currentStep: event.step, + isComplete: event.step === 'Done', + isProcessing: event.step !== 'Done' && !event.error, + proxyVersion: event.proxy_version ?? null, + errorMessage: event.error + ? event.message || m.edge_setup_adaptation_error_default() + : null, + proxyLogs: event.logs && event.logs.length > 0 ? [...event.logs] : [], + }); + }, + [setEdgeAdaptationState], + ); + + const sse = useSSEController( + '/api/v1/proxy/setup/stream', + { + ip_or_domain: edgeComponentWizardStore.ip_or_domain, + grpc_port: edgeComponentWizardStore.grpc_port, + common_name: edgeComponentWizardStore.common_name, + }, + { + onMessage: handleEvent, + }, + ); + + const handleBack = () => { + useEdgeWizardStore.getState().resetEdgeAdaptationState(); + setActiveStep(EdgeSetupStep.EdgeComponent); + }; + + const handleNext = () => { + setActiveStep(EdgeSetupStep.Confirmation); + }; + + const steps: SetupStep[] = useMemo( + () => [ + { + id: 'CheckingConfiguration', + title: m.edge_setup_adaptation_checking_configuration(), + }, + { + id: 'CheckingAvailability', + title: m.edge_setup_adaptation_checking_availability({ + ip_or_domain: edgeComponentWizardStore.ip_or_domain, + grpc_port: edgeComponentWizardStore.grpc_port.toString(), + }), + }, + { + id: 'CheckingVersion', + title: edgeAdaptationState.proxyVersion + ? m.edge_setup_adaptation_checking_version_with_value({ + proxyVersion: edgeAdaptationState.proxyVersion, + }) + : m.edge_setup_adaptation_checking_version(), + }, + { + id: 'ObtainingCsr', + title: m.edge_setup_adaptation_obtaining_csr(), + }, + { + id: 'SigningCertificate', + title: m.edge_setup_adaptation_signing_certificate(), + }, + { + id: 'ConfiguringTls', + title: m.edge_setup_adaptation_configuring_tls(), + }, + ], + [edgeComponentWizardStore, edgeAdaptationState.proxyVersion], + ); + + const stepDone = useCallback( + (stepId: SetupStepId): boolean => { + const stepIndex = steps.findIndex((step) => step.id === stepId); + const currentStepIndex = edgeAdaptationState.currentStep + ? steps.findIndex((step) => step.id === edgeAdaptationState.currentStep) + : -1; + return stepIndex < currentStepIndex || edgeAdaptationState.isComplete; + }, + [edgeAdaptationState.isComplete, edgeAdaptationState.currentStep, steps], + ); + + const stepLoading = useCallback( + (stepId: SetupStepId): boolean => { + return ( + edgeAdaptationState.isProcessing && edgeAdaptationState.currentStep === stepId + ); + }, + [edgeAdaptationState.isProcessing, edgeAdaptationState.currentStep], + ); + + const stepError = useCallback( + (stepId: SetupStepId): string | null => { + if ( + edgeAdaptationState.errorMessage && + edgeAdaptationState.currentStep === stepId + ) { + return edgeAdaptationState.errorMessage; + } + return null; + }, + [edgeAdaptationState.errorMessage, edgeAdaptationState.currentStep], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: mount only + useEffect(() => { + resetEdgeAdaptationState(); + sse.start(); + + return () => { + sse.stop(); + }; + }, []); + + return ( + +
+ {steps.map((step, index) => ( + + {edgeAdaptationState.proxyLogs.length > 0 ? ( + <> + + + + ) : null} + +
+
+
+
+ ))} +
+ +
+ ); +}; diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx new file mode 100644 index 000000000..c40c71687 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx @@ -0,0 +1,153 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +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 { EdgeSetupStep } from '../types'; +import { useEdgeWizardStore } from '../useEdgeWizardStore'; +import './style.scss'; +import { validateIpOrDomain } from '../../../shared/validators'; + +type FormFields = StoreValues; + +type StoreValues = { + common_name: string; + ip_or_domain: string; + grpc_port: number; + public_domain: string; +}; + +export const SetupEdgeComponentStep = () => { + const setActiveStep = useEdgeWizardStore((s) => s.setActiveStep); + const navigate = useNavigate(); + const defaultValues = useEdgeWizardStore( + useShallow( + (s): FormFields => ({ + common_name: s.common_name, + ip_or_domain: s.ip_or_domain, + grpc_port: s.grpc_port, + public_domain: s.public_domain, + }), + ), + ); + + const handleNext = () => { + form.handleSubmit(); + }; + + const handleBack = () => { + useEdgeWizardStore.getState().reset(); + navigate({ + to: '/edge-wizard', + replace: true, + }); + }; + + const formSchema = useMemo( + () => + z.object({ + common_name: z + .string() + .min(1, m.edge_setup_component_error_common_name_required()), + ip_or_domain: z + .string() + .min(1, m.edge_setup_component_error_ip_or_domain_required()) + .refine((val) => validateIpOrDomain(val, false, true)), + grpc_port: z + .number() + .min(1, m.edge_setup_component_error_grpc_port_required()) + .max(65535, m.edge_setup_component_error_grpc_port_max()), + public_domain: z + .string() + .min(1, m.edge_setup_component_error_public_domain_required()), + }), + [], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useEdgeWizardStore.setState({ + ...value, + }); + setActiveStep(EdgeSetupStep.EdgeAdaptation); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
+ +
+ ); +}; diff --git a/web/src/pages/EdgeSetupPage/steps/style.scss b/web/src/pages/EdgeSetupPage/steps/style.scss new file mode 100644 index 000000000..10e13b988 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/style.scss @@ -0,0 +1,11 @@ +.wizard-card { + .modal-controls > .buttons { + display: flex; + flex-grow: 1; + justify-content: space-between; + } + + .controls { + padding-top: 0; + } +} diff --git a/web/src/pages/EdgeSetupPage/steps/types.ts b/web/src/pages/EdgeSetupPage/steps/types.ts new file mode 100644 index 000000000..b19078c10 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/types.ts @@ -0,0 +1,29 @@ +export type SetupEvent = { + step: SetupStepId; + proxy_version?: string; + message?: string; + logs?: string[]; + error: boolean; +}; + +export type SetupStep = { + id: SetupStepId; + title: string; +}; + +export type SetupStepId = + | 'CheckingConfiguration' + | 'CheckingAvailability' + | 'CheckingVersion' + | 'ObtainingCsr' + | 'SigningCertificate' + | 'ConfiguringTls' + | 'Done'; +// biome-ignore lint/suspicious/noExplicitAny: SSE hook accepts various data types +export interface SSEHookOptions { + onMessage?: (data: T) => void; + onError?: (error: Event) => void; + onOpen?: () => void; + parseJSON?: boolean; + params?: Record; +} diff --git a/web/src/pages/EdgeSetupPage/steps/useSSEController.tsx b/web/src/pages/EdgeSetupPage/steps/useSSEController.tsx new file mode 100644 index 000000000..da993a82b --- /dev/null +++ b/web/src/pages/EdgeSetupPage/steps/useSSEController.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { SSEHookOptions } from './types'; + +// SSE (Server-Sent Events) controller hook for processing real-time events received from the backend. +// biome-ignore lint/suspicious/noExplicitAny: SSE hook accepts various data types +export function useSSEController( + url: string, + params: Record, + options: SSEHookOptions = {}, +) { + const eventSourceRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + const buildUrl = useCallback(() => { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null) qs.append(k, String(v)); + }); + return qs.toString() ? `${url}?${qs}` : url; + }, [url, params]); + + const stop = useCallback(() => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + setIsConnected(false); + }, []); + + const start = useCallback(() => { + if (eventSourceRef.current) return; + + const es = new EventSource(buildUrl()); + eventSourceRef.current = es; + + es.onopen = () => { + setIsConnected(true); + setError(null); + options.onOpen?.(); + }; + + es.onmessage = (e) => { + const data = options.parseJSON === false ? e.data : JSON.parse(e.data); + options.onMessage?.(data); + }; + + es.onerror = (e) => { + setError(e); + setIsConnected(false); + options.onError?.(e); + stop(); + }; + }, [buildUrl, options, stop]); + + const restart = useCallback(() => { + stop(); + start(); + }, [start, stop]); + + useEffect(() => stop, [stop]); + + return { start, stop, restart, isConnected, error }; +} diff --git a/web/src/pages/EdgeSetupPage/style.scss b/web/src/pages/EdgeSetupPage/style.scss new file mode 100644 index 000000000..9ca97f6e8 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/style.scss @@ -0,0 +1,135 @@ +/* stylelint-disable no-descending-specificity */ +#setup-page { + background-color: var(--bg-muted); + width: 100%; + min-height: 100dvh; +} + +#setup-page > .content-limiter { + display: flex; + flex-flow: row; + align-items: flex-start; + justify-content: center; +} + +#setup-page .page-grid { + display: flex; + flex-flow: column; + align-items: flex-start; + justify-content: flex-start; + width: 100%; + max-width: 1120px; + box-sizing: border-box; + min-height: 100dvh; +} + +#setup-page header { + height: 60px; + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + padding-top: var(--spacing-2xl); + padding-bottom: var(--spacing-4xl); +} + +#setup-page #content-card { + display: grid; + grid-template-columns: 667fr 443fr; + border-radius: var(--radius-xxl); + background-color: var(--bg-default); + box-shadow: var(--menu-shadow); + overflow: hidden; + + & > .main-track { + box-sizing: border-box; + padding: var(--spacing-4xl); + display: flex; + flex-direction: column; + justify-content: space-between; + } + + & > .image { + height: 100%; + width: 100%; + max-width: 100%; + position: relative; + overflow: hidden; + + video { + width: 100%; + min-height: 657px; + min-width: 443px; + object-fit: cover; + } + } +} + +#docs-card { + display: grid; + grid-template-columns: 54px 1fr; + background-color: transparent; + border: var(--border-1) solid var(--border-disabled); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + column-gap: var(--spacing-2xl); + + .image-track { + width: 100%; + max-width: 100%; + overflow: hidden; + + img { + width: 100%; + } + } + + .content { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + p { + font: var(--t-body-sm-400); + color: var(--fg-muted); + } + } +} + +#setup-page footer { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + width: 100%; + margin-top: auto; + padding-top: var(--spacing-4xl); + padding-bottom: var(--spacing-2xl); + + & > div:nth-child(2) { + margin-left: auto; + } + + div p { + font: var(--t-body-xs-400); + color: var(--fg-muted); + + span { + color: inherit; + font: inherit; + } + } + + div:nth-child(1) { + a { + color: inherit; + } + } + + div:nth-child(2) { + a { + color: var(--fg-action); + text-decoration: none; + } + } +} diff --git a/web/src/pages/EdgeSetupPage/types.ts b/web/src/pages/EdgeSetupPage/types.ts new file mode 100644 index 000000000..de3524491 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/types.ts @@ -0,0 +1,7 @@ +export const EdgeSetupStep = { + EdgeComponent: 'edgeComponent', + EdgeAdaptation: 'edgeAdaptation', + Confirmation: 'confirmation', +} as const; + +export type EdgeSetupStepValue = (typeof EdgeSetupStep)[keyof typeof EdgeSetupStep]; diff --git a/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx new file mode 100644 index 000000000..adf2dc199 --- /dev/null +++ b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx @@ -0,0 +1,93 @@ +import { omit } from 'lodash-es'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import type { SetupStepId } from './steps/types'; +import { EdgeSetupStep, type EdgeSetupStepValue } from './types'; + +type EdgeAdaptationState = { + isProcessing: boolean; + isComplete: boolean; + currentStep: SetupStepId | null; + errorMessage: string | null; + proxyVersion: string | null; + proxyLogs: string[]; +}; + +type StoreValues = { + activeStep: EdgeSetupStepValue; + showWelcome: boolean; + common_name: string; + ip_or_domain: string; + grpc_port: number; + public_domain: string; + edgeAdaptationState: EdgeAdaptationState; +}; + +type StoreMethods = { + reset: () => void; + start: (values?: Partial) => void; + setActiveStep: (step: EdgeSetupStepValue) => void; + setShowWelcome: (show: boolean) => void; + updateValues: (values: Partial) => void; + resetEdgeAdaptationState: () => void; + setEdgeAdaptationState: (state: EdgeAdaptationState) => void; +}; + +const edgeAdaptationStateDefaults: EdgeAdaptationState = { + isProcessing: false, + isComplete: false, + currentStep: null, + errorMessage: null, + proxyVersion: null, + proxyLogs: [], +}; + +const defaults: StoreValues = { + activeStep: EdgeSetupStep.EdgeComponent, + showWelcome: true, + common_name: '', + ip_or_domain: '', + grpc_port: 50051, + public_domain: '', + edgeAdaptationState: edgeAdaptationStateDefaults, +}; + +export const useEdgeWizardStore = create()( + persist( + (set) => ({ + ...defaults, + reset: () => set(defaults), + start: (initial) => { + set({ + ...defaults, + ...initial, + }); + }, + setActiveStep: (step) => set({ activeStep: step }), + setShowWelcome: (show) => set({ showWelcome: show }), + updateValues: (values) => set(values), + resetEdgeAdaptationState: () => + set(() => ({ + edgeAdaptationState: { ...edgeAdaptationStateDefaults }, + })), + setEdgeAdaptationState: (state: Partial) => + set((s) => ({ + edgeAdaptationState: { ...s.edgeAdaptationState, ...state }, + })), + }), + { + name: 'setup-wizard-store', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => + omit(state, [ + 'reset', + 'start', + 'setActiveStep', + 'updateValues', + 'setShowWelcome', + 'resetEdgeAdaptationState', + 'setEdgeAdaptationState', + ]), + }, + ), +); diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index adaabe675..18ed878a2 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as AuthMfaTotpRouteImport } from './routes/auth/mfa/totp' import { Route as AuthMfaRecoveryRouteImport } from './routes/auth/mfa/recovery' import { Route as AuthMfaEmailRouteImport } from './routes/auth/mfa/email' import { Route as AuthorizedWizardSetupWizardRouteImport } from './routes/_authorized/_wizard/setup-wizard' +import { Route as AuthorizedWizardEdgeWizardRouteImport } from './routes/_authorized/_wizard/edge-wizard' import { Route as AuthorizedWizardAddLocationRouteImport } from './routes/_authorized/_wizard/add-location' import { Route as AuthorizedWizardAddExternalOpenidRouteImport } from './routes/_authorized/_wizard/add-external-openid' import { Route as AuthorizedDefaultWebhooksRouteImport } from './routes/_authorized/_default/webhooks' @@ -135,6 +136,12 @@ const AuthorizedWizardSetupWizardRoute = path: '/setup-wizard', getParentRoute: () => AuthorizedRoute, } as any) +const AuthorizedWizardEdgeWizardRoute = + AuthorizedWizardEdgeWizardRouteImport.update({ + id: '/_wizard/edge-wizard', + path: '/edge-wizard', + getParentRoute: () => AuthorizedRoute, + } as any) const AuthorizedWizardAddLocationRoute = AuthorizedWizardAddLocationRouteImport.update({ id: '/_wizard/add-location', @@ -296,6 +303,7 @@ export interface FileRoutesByFullPath { '/webhooks': typeof AuthorizedDefaultWebhooksRoute '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute + '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -336,6 +344,7 @@ export interface FileRoutesByTo { '/webhooks': typeof AuthorizedDefaultWebhooksRoute '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute + '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -380,6 +389,7 @@ export interface FileRoutesById { '/_authorized/_default/webhooks': typeof AuthorizedDefaultWebhooksRoute '/_authorized/_wizard/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/_authorized/_wizard/add-location': typeof AuthorizedWizardAddLocationRoute + '/_authorized/_wizard/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/_authorized/_wizard/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -423,6 +433,7 @@ export interface FileRouteTypes { | '/webhooks' | '/add-external-openid' | '/add-location' + | '/edge-wizard' | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -463,6 +474,7 @@ export interface FileRouteTypes { | '/webhooks' | '/add-external-openid' | '/add-location' + | '/edge-wizard' | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -506,6 +518,7 @@ export interface FileRouteTypes { | '/_authorized/_default/webhooks' | '/_authorized/_wizard/add-external-openid' | '/_authorized/_wizard/add-location' + | '/_authorized/_wizard/edge-wizard' | '/_authorized/_wizard/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -659,6 +672,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedWizardSetupWizardRouteImport parentRoute: typeof AuthorizedRoute } + '/_authorized/_wizard/edge-wizard': { + id: '/_authorized/_wizard/edge-wizard' + path: '/edge-wizard' + fullPath: '/edge-wizard' + preLoaderRoute: typeof AuthorizedWizardEdgeWizardRouteImport + parentRoute: typeof AuthorizedRoute + } '/_authorized/_wizard/add-location': { id: '/_authorized/_wizard/add-location' path: '/add-location' @@ -893,6 +913,7 @@ interface AuthorizedRouteChildren { AuthorizedDefaultRoute: typeof AuthorizedDefaultRouteWithChildren AuthorizedWizardAddExternalOpenidRoute: typeof AuthorizedWizardAddExternalOpenidRoute AuthorizedWizardAddLocationRoute: typeof AuthorizedWizardAddLocationRoute + AuthorizedWizardEdgeWizardRoute: typeof AuthorizedWizardEdgeWizardRoute AuthorizedWizardSetupWizardRoute: typeof AuthorizedWizardSetupWizardRoute } @@ -901,6 +922,7 @@ const AuthorizedRouteChildren: AuthorizedRouteChildren = { AuthorizedWizardAddExternalOpenidRoute: AuthorizedWizardAddExternalOpenidRoute, AuthorizedWizardAddLocationRoute: AuthorizedWizardAddLocationRoute, + AuthorizedWizardEdgeWizardRoute: AuthorizedWizardEdgeWizardRoute, AuthorizedWizardSetupWizardRoute: AuthorizedWizardSetupWizardRoute, } diff --git a/web/src/routes/_authorized/_wizard/edge-wizard.tsx b/web/src/routes/_authorized/_wizard/edge-wizard.tsx new file mode 100644 index 000000000..38fdbce00 --- /dev/null +++ b/web/src/routes/_authorized/_wizard/edge-wizard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { EdgeSetupPage } from '../../../pages/EdgeSetupPage/EdgeSetupPage'; + +export const Route = createFileRoute('/_authorized/_wizard/edge-wizard')({ + component: EdgeSetupPage, +}); diff --git a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx index 91fecae8b..72ad14fcc 100644 --- a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx +++ b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx @@ -10,6 +10,7 @@ import { LayoutGrid } from '../../LayoutGrid/LayoutGrid'; import type { WizardPageConfig } from '../types'; import { WizardStepsCard } from '../WizardStepsCard/WizardStepsCard'; import { WizardTop } from '../WizardTop/WizardTop'; +import { WizardWelcomePage } from '../WizardWelcomePage/WizardWelcomePage'; type Props = HTMLProps & PropsWithChildren & @@ -25,6 +26,8 @@ export const WizardPage = ({ title, children, onClose, + welcomePageConfig, + showWelcome, ...containerProps }: Props) => { const activeStep = steps[activeStepId]; @@ -61,23 +64,29 @@ export const WizardPage = ({
-
-

{title}

- -

{subtitle}

- - -
-
- - -

{activeStep.label}

- {isPresent(activeStep.description) && ( -

{activeStep.description}

- )} - - {children} -
+ {welcomePageConfig && showWelcome ? ( + + ) : ( + <> +
+

{title}

+ +

{subtitle}

+ + +
+
+ + +

{activeStep.label}

+ {isPresent(activeStep.description) && ( +

{activeStep.description}

+ )} + + {children} +
+ + )}
diff --git a/web/src/shared/components/wizard/WizardPage/style.scss b/web/src/shared/components/wizard/WizardPage/style.scss index db7e173f5..9b7ef34b5 100644 --- a/web/src/shared/components/wizard/WizardPage/style.scss +++ b/web/src/shared/components/wizard/WizardPage/style.scss @@ -49,4 +49,8 @@ color: var(--fg-muted); } } + + .layout-grid > .wizard-welcome-page { + grid-column: 1 / 13; + } } diff --git a/web/src/shared/components/wizard/WizardWelcomePage/WizardWelcomePage.tsx b/web/src/shared/components/wizard/WizardWelcomePage/WizardWelcomePage.tsx new file mode 100644 index 000000000..d701461e6 --- /dev/null +++ b/web/src/shared/components/wizard/WizardWelcomePage/WizardWelcomePage.tsx @@ -0,0 +1,45 @@ +import './style.scss'; +import { AppText } from '../../../defguard-ui/components/AppText/AppText'; +import { ExternalLink } from '../../../defguard-ui/components/ExternalLink/ExternalLink'; +import { SizedBox } from '../../../defguard-ui/components/SizedBox/SizedBox'; +import { TextStyle, ThemeSpacing, ThemeVariable } from '../../../defguard-ui/types'; +import type { WizardWelcomePageConfig } from '../types'; +import fileIcon from './assets/file_icon.png'; + +type Props = WizardWelcomePageConfig; + +export const WizardWelcomePage = ({ + title, + subtitle, + content, + media, + docsLink = 'https://docs.defguard.net/', + docsText = 'Before installation, we recommend reading our documentation to understand the system architecture and core components.', +}: Props) => { + return ( +
+
+
+

{title}

+ + + {subtitle} + +
{content}
+
+
+
+ Documentation +
+
+

{docsText}

+
+ {`Read documentation`} +
+
+
+
+
{media}
+
+ ); +}; diff --git a/web/src/shared/components/wizard/WizardWelcomePage/assets/file_icon.png b/web/src/shared/components/wizard/WizardWelcomePage/assets/file_icon.png new file mode 100644 index 000000000..f0cafdc06 Binary files /dev/null and b/web/src/shared/components/wizard/WizardWelcomePage/assets/file_icon.png differ diff --git a/web/src/shared/components/wizard/WizardWelcomePage/index.ts b/web/src/shared/components/wizard/WizardWelcomePage/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/shared/components/wizard/WizardWelcomePage/style.scss b/web/src/shared/components/wizard/WizardWelcomePage/style.scss new file mode 100644 index 000000000..ca839562e --- /dev/null +++ b/web/src/shared/components/wizard/WizardWelcomePage/style.scss @@ -0,0 +1,81 @@ +/* stylelint-disable no-descending-specificity */ +.wizard-welcome-page { + display: grid; + grid-template-columns: 667fr 443fr; + border-radius: var(--radius-xxl); + background-color: var(--bg-default); + box-shadow: var(--menu-shadow); + overflow: hidden; + width: 100%; + max-width: 1110px; + + .main-track { + box-sizing: border-box; + padding: var(--spacing-4xl); + display: flex; + flex-direction: column; + justify-content: space-between; + + .top-content { + h1 { + font: var(--t-title-h3); + color: var(--fg-default); + } + } + } + + .media-track { + height: 100%; + width: 100%; + max-width: 100%; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + video { + width: 100%; + min-height: 657px; + min-width: 443px; + object-fit: cover; + } + } + + #docs-card { + display: grid; + grid-template-columns: 54px 1fr; + background-color: transparent; + border: var(--border-1) solid var(--border-disabled); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + column-gap: var(--spacing-2xl); + + .image-track { + width: 100%; + max-width: 100%; + overflow: hidden; + + img { + width: 100%; + } + } + + .content { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + p { + font: var(--t-body-sm-400); + color: var(--fg-muted); + } + } + } +} diff --git a/web/src/shared/components/wizard/types.ts b/web/src/shared/components/wizard/types.ts index cb0976455..ab7ea791b 100644 --- a/web/src/shared/components/wizard/types.ts +++ b/web/src/shared/components/wizard/types.ts @@ -4,6 +4,8 @@ export interface WizardPageConfig { activeStep: string | number; steps: WizardPageStepsConfig; relatedDocs?: WizardDocsLink[]; + welcomePageConfig?: WizardWelcomePageConfig; + showWelcome?: boolean; } export interface WizardDocsLink { @@ -19,4 +21,13 @@ export interface WizardPageStep { hidden?: boolean; } +export interface WizardWelcomePageConfig { + title: string; + subtitle: string; + content: React.ReactNode; + media: React.ReactNode; + docsLink?: string; + docsText?: string; +} + export type WizardPageStepsConfig = Record;