diff --git a/.sqlx/query-cd6c70e9b46c5cdce053d6f0772612388c1e322dbc7ce93d92251b3a82caabef.json b/.sqlx/query-5af0fbf61295a5a23149c6248ea0b4a7afcbee1b63e34932c143f4697a0bc2cc.json similarity index 73% rename from .sqlx/query-cd6c70e9b46c5cdce053d6f0772612388c1e322dbc7ce93d92251b3a82caabef.json rename to .sqlx/query-5af0fbf61295a5a23149c6248ea0b4a7afcbee1b63e34932c143f4697a0bc2cc.json index cb69aae3b7..0ee0514bda 100644 --- a/.sqlx/query-cd6c70e9b46c5cdce053d6f0772612388c1e322dbc7ce93d92251b3a82caabef.json +++ b/.sqlx/query-5af0fbf61295a5a23149c6248ea0b4a7afcbee1b63e34932c143f4697a0bc2cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"gateway\" (\"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id", + "query": "INSERT INTO \"gateway\" (\"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\",\"name\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id", "describe": { "columns": [ { @@ -18,6 +18,7 @@ "Timestamp", "Bool", "Timestamp", + "Text", "Text" ] }, @@ -25,5 +26,5 @@ false ] }, - "hash": "cd6c70e9b46c5cdce053d6f0772612388c1e322dbc7ce93d92251b3a82caabef" + "hash": "5af0fbf61295a5a23149c6248ea0b4a7afcbee1b63e34932c143f4697a0bc2cc" } diff --git a/.sqlx/query-9655c2e6a8f749fe1ea966dcdc0ea2080a4c27137278b2fb761d3b279b69f9db.json b/.sqlx/query-ae3e3cef524f2a911808bf72e7c57b7f32e22adefc9b9185a9b3cd80c169a6e2.json similarity index 83% rename from .sqlx/query-9655c2e6a8f749fe1ea966dcdc0ea2080a4c27137278b2fb761d3b279b69f9db.json rename to .sqlx/query-ae3e3cef524f2a911808bf72e7c57b7f32e22adefc9b9185a9b3cd80c169a6e2.json index 53deb53137..77722daf50 100644 --- a/.sqlx/query-9655c2e6a8f749fe1ea966dcdc0ea2080a4c27137278b2fb761d3b279b69f9db.json +++ b/.sqlx/query-ae3e3cef524f2a911808bf72e7c57b7f32e22adefc9b9185a9b3cd80c169a6e2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\" FROM \"gateway\"", + "query": "SELECT id, \"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\",\"name\" FROM \"gateway\"", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "ordinal": 8, "name": "version", "type_info": "Text" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Text" } ], "parameters": { @@ -61,8 +66,9 @@ true, false, true, - true + true, + false ] }, - "hash": "9655c2e6a8f749fe1ea966dcdc0ea2080a4c27137278b2fb761d3b279b69f9db" + "hash": "ae3e3cef524f2a911808bf72e7c57b7f32e22adefc9b9185a9b3cd80c169a6e2" } diff --git a/.sqlx/query-671c3ef062fa5c901d26adb86c75b7ccad57d9cc9caa41502cbe85c6eafb087e.json b/.sqlx/query-b43694450d7abe3b93ea88fa7c95c38d3e2deb43d5ca3458724deb3ead69389a.json similarity index 83% rename from .sqlx/query-671c3ef062fa5c901d26adb86c75b7ccad57d9cc9caa41502cbe85c6eafb087e.json rename to .sqlx/query-b43694450d7abe3b93ea88fa7c95c38d3e2deb43d5ca3458724deb3ead69389a.json index f673e531f4..10051d513f 100644 --- a/.sqlx/query-671c3ef062fa5c901d26adb86c75b7ccad57d9cc9caa41502cbe85c6eafb087e.json +++ b/.sqlx/query-b43694450d7abe3b93ea88fa7c95c38d3e2deb43d5ca3458724deb3ead69389a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\" FROM \"gateway\" WHERE id = $1", + "query": "SELECT id, \"network_id\",\"url\",\"hostname\",\"connected_at\",\"disconnected_at\",\"has_certificate\",\"certificate_expiry\",\"version\",\"name\" FROM \"gateway\" WHERE id = $1", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "ordinal": 8, "name": "version", "type_info": "Text" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Text" } ], "parameters": { @@ -63,8 +68,9 @@ true, false, true, - true + true, + false ] }, - "hash": "671c3ef062fa5c901d26adb86c75b7ccad57d9cc9caa41502cbe85c6eafb087e" + "hash": "b43694450d7abe3b93ea88fa7c95c38d3e2deb43d5ca3458724deb3ead69389a" } diff --git a/.sqlx/query-d10c9a7b0b391aeb8b4869f6bddf997807b66e0b532da747b146513c34e15c5c.json b/.sqlx/query-d10c9a7b0b391aeb8b4869f6bddf997807b66e0b532da747b146513c34e15c5c.json index f007870a8d..b843b3c06c 100644 --- a/.sqlx/query-d10c9a7b0b391aeb8b4869f6bddf997807b66e0b532da747b146513c34e15c5c.json +++ b/.sqlx/query-d10c9a7b0b391aeb8b4869f6bddf997807b66e0b532da747b146513c34e15c5c.json @@ -45,6 +45,11 @@ }, { "ordinal": 8, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 9, "name": "version", "type_info": "Text" } @@ -63,6 +68,7 @@ true, false, true, + false, true ] }, diff --git a/.sqlx/query-e9ca71b61f7a3736ca335d90aca36ab5a93dc8a00ad622267f13b3cd4cdb4a5a.json b/.sqlx/query-e9ca71b61f7a3736ca335d90aca36ab5a93dc8a00ad622267f13b3cd4cdb4a5a.json new file mode 100644 index 0000000000..d3ffd878ef --- /dev/null +++ b/.sqlx/query-e9ca71b61f7a3736ca335d90aca36ab5a93dc8a00ad622267f13b3cd4cdb4a5a.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM gateway WHERE url = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "network_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "hostname", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 5, + "name": "disconnected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 6, + "name": "has_certificate", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "certificate_expiry", + "type_info": "Timestamp" + }, + { + "ordinal": 8, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "version", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + true + ] + }, + "hash": "e9ca71b61f7a3736ca335d90aca36ab5a93dc8a00ad622267f13b3cd4cdb4a5a" +} diff --git a/.sqlx/query-16802280f46083a640c0f084479e4aee1d1b2098133ff953c1188346c7cb252e.json b/.sqlx/query-ed3266f5f0d7b1613ad8745c9be953a7d9ef0becedf668c1d2225a1673003c77.json similarity index 78% rename from .sqlx/query-16802280f46083a640c0f084479e4aee1d1b2098133ff953c1188346c7cb252e.json rename to .sqlx/query-ed3266f5f0d7b1613ad8745c9be953a7d9ef0becedf668c1d2225a1673003c77.json index 67293eca96..48849d4d38 100644 --- a/.sqlx/query-16802280f46083a640c0f084479e4aee1d1b2098133ff953c1188346c7cb252e.json +++ b/.sqlx/query-ed3266f5f0d7b1613ad8745c9be953a7d9ef0becedf668c1d2225a1673003c77.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"gateway\" SET \"network_id\" = $2,\"url\" = $3,\"hostname\" = $4,\"connected_at\" = $5,\"disconnected_at\" = $6,\"has_certificate\" = $7,\"certificate_expiry\" = $8,\"version\" = $9 WHERE id = $1", + "query": "UPDATE \"gateway\" SET \"network_id\" = $2,\"url\" = $3,\"hostname\" = $4,\"connected_at\" = $5,\"disconnected_at\" = $6,\"has_certificate\" = $7,\"certificate_expiry\" = $8,\"version\" = $9,\"name\" = $10 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -13,10 +13,11 @@ "Timestamp", "Bool", "Timestamp", + "Text", "Text" ] }, "nullable": [] }, - "hash": "16802280f46083a640c0f084479e4aee1d1b2098133ff953c1188346c7cb252e" + "hash": "ed3266f5f0d7b1613ad8745c9be953a7d9ef0becedf668c1d2225a1673003c77" } diff --git a/Cargo.lock b/Cargo.lock index 82c1bfebcf..6f5dff70fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,6 +1139,7 @@ dependencies = [ "secrecy", "tokio", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/defguard/Cargo.toml b/crates/defguard/Cargo.toml index e6dda2964a..777ec89353 100644 --- a/crates/defguard/Cargo.toml +++ b/crates/defguard/Cargo.toml @@ -26,3 +26,4 @@ dotenvy = "0.15" secrecy = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index db40185172..5041d79782 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -47,6 +47,7 @@ use tokio::sync::{ broadcast, mpsc::{channel, unbounded_channel}, }; +use tracing_subscriber::util::SubscriberInitExt; #[macro_use] extern crate tracing; @@ -61,11 +62,13 @@ async fn main() -> Result<(), anyhow::Error> { .set(config.clone()) .expect("Failed to initialize server config."); - // initialize tracing with version formatter - defguard_version::tracing::init( - defguard_version::Version::parse(VERSION)?, + let subscriber = tracing_subscriber::registry(); + defguard_version::tracing::with_version_formatters( + &defguard_version::Version::parse(VERSION)?, &config.log_level, - )?; + subscriber, + ) + .init(); info!("Starting ... version v{VERSION}"); debug!("Using config: {config:?}"); diff --git a/crates/defguard_common/src/db/models/gateway.rs b/crates/defguard_common/src/db/models/gateway.rs index ccd7edfce4..613d5f2b47 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -18,6 +18,7 @@ pub struct Gateway { pub has_certificate: bool, pub certificate_expiry: Option, pub version: Option, + pub name: String, } impl Gateway { @@ -34,7 +35,7 @@ impl Gateway { impl Gateway { #[must_use] - pub fn new>(network_id: Id, url: S) -> Self { + pub fn new>(network_id: Id, url: S, name: S) -> Self { Self { id: NoId, network_id, @@ -45,6 +46,7 @@ impl Gateway { has_certificate: false, certificate_expiry: None, version: None, + name: name.into(), } } } @@ -120,6 +122,18 @@ impl Gateway { Ok(()) } + + // TODO: Split the URL into address and port fields just like in proxy + pub async fn find_by_url<'e, E>(executor: E, url: &str) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + let record = query_as!(Self, "SELECT * FROM gateway WHERE url = $1", url) + .fetch_optional(executor) + .await?; + + Ok(record) + } } impl fmt::Display for Gateway { diff --git a/crates/defguard_core/src/grpc/gateway/handler.rs b/crates/defguard_core/src/grpc/gateway/handler.rs index fec303fa1c..4aa617099e 100644 --- a/crates/defguard_core/src/grpc/gateway/handler.rs +++ b/crates/defguard_core/src/grpc/gateway/handler.rs @@ -4,7 +4,7 @@ use std::{ }; use chrono::{DateTime, Utc}; -use defguard_certs::{Csr, der_to_pem}; +use defguard_certs::der_to_pem; use defguard_common::{ VERSION, db::{ @@ -18,8 +18,7 @@ use defguard_common::{ }; use defguard_mail::Mail; use defguard_proto::gateway::{ - CoreResponse, DerPayload, InitialSetupInfo, PeerStats, core_request, core_response, - gateway_client, gateway_setup_client, + CoreResponse, PeerStats, core_request, core_response, gateway_client, }; use defguard_version::client::ClientVersionInterceptor; use reqwest::Url; @@ -46,6 +45,7 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Scheme { + #[allow(dead_code)] Http, Https, } @@ -118,10 +118,6 @@ impl GatewayHandler { }) } - pub const fn has_certificate(&self) -> bool { - self.gateway.has_certificate - } - fn endpoint(&self, scheme: Scheme) -> Result { let mut url = self.url.clone(); @@ -278,94 +274,6 @@ impl GatewayHandler { Ok(device) } - pub(crate) async fn handle_setup(&mut self) -> Result<(), GatewayError> { - debug!("Handling initial setup for Gateway {}", self.gateway); - let endpoint = self.endpoint(Scheme::Http)?; - let uri = endpoint.uri().to_string(); - - let hostname = self - .url - .host_str() - .ok_or_else(|| { - error!("Failed to get hostname from Gateway URL {}", self.url); - GatewayError::EndpointError(format!( - "Failed to get hostname from Gateway URL {}", - self.url - )) - })? - .to_string(); - - #[cfg(not(test))] - let channel = endpoint.connect_lazy(); - #[cfg(test)] - let channel = endpoint.connect_with_connector_lazy(tower::service_fn( - |_: tonic::transport::Uri| async { - Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new( - tokio::net::UnixStream::connect(super::TONIC_SOCKET).await?, - )) - }, - )); - - debug!("Connecting to Gateway {uri}"); - let interceptor = ClientVersionInterceptor::new( - Version::parse(VERSION).expect("failed to parse self version"), - ); - let mut client = - gateway_setup_client::GatewaySetupClient::with_interceptor(channel, interceptor); - - let request = InitialSetupInfo { - cert_hostname: hostname, - }; - - let response = client.start(request).await?; - let response = response.into_inner(); - - let csr = Csr::from_der(&response.der_data)?; - - let settings = Settings::get_current_settings(); - - let ca_cert_der = settings.ca_cert_der.ok_or_else(|| { - GatewayError::ConfigurationError( - "CA certificate DER not found in settings for Gateway setup".to_string(), - ) - })?; - let ca_key_pair = settings.ca_key_der.ok_or_else(|| { - GatewayError::ConfigurationError( - "CA key pairs DER not found in settings for Gateway setup".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 req = DerPayload { - der_data: cert.der().to_vec(), - }; - - client.send_cert(req).await?; - - self.gateway.has_certificate = true; - self.gateway.certificate_expiry = - Some(defguard_certs::get_certificate_expiry(cert.der())?); - self.gateway.save(&self.pool).await?; - } - Err(err) => { - error!("Failed to sign CSR: {err}"); - } - } - - debug!( - "Saving information about issued certificate to the database for Gateway {}", - self.gateway - ); - - Ok(()) - } - /// Connect to Gateway and handle its messages through gRPC. pub(crate) async fn handle_connection(&mut self) -> Result<(), GatewayError> { let endpoint = self.endpoint(Scheme::Https)?; diff --git a/crates/defguard_core/src/grpc/gateway/mod.rs b/crates/defguard_core/src/grpc/gateway/mod.rs index 73e95b9fa6..49acc4391a 100644 --- a/crates/defguard_core/src/grpc/gateway/mod.rs +++ b/crates/defguard_core/src/grpc/gateway/mod.rs @@ -217,7 +217,6 @@ fn gen_config( } const GATEWAY_TABLE_TRIGGER: &str = "gateway_change"; -const GATEWAY_SETUP_DELAY: Duration = Duration::from_secs(1); const GATEWAY_RECONNECT_DELAY: Duration = Duration::from_secs(5); /// Bi-directional gRPC stream for communication with Defguard Gateway. @@ -241,16 +240,6 @@ pub async fn run_grpc_gateway_stream( )?; let abort_handle = tasks.spawn(async move { loop { - if gateway_handler.has_certificate() { - info!("A certificate was already issued for Gateway, proceeding to connection"); - } else { - info!("Gateway does not have a valid certificate, proceeding to setup"); - if let Err(err) = gateway_handler.handle_setup().await { - warn!("Gateway setup failed: {err}, will try to connect anyway..."); - } else { - tokio::time::sleep(GATEWAY_SETUP_DELAY).await; - } - } if let Err(err) = gateway_handler.handle_connection().await { error!("Gateway connection error: {err}, retrying in 5 seconds..."); tokio::time::sleep(GATEWAY_RECONNECT_DELAY).await; diff --git a/crates/defguard_core/src/handlers/proxy_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs similarity index 53% rename from crates/defguard_core/src/handlers/proxy_setup.rs rename to crates/defguard_core/src/handlers/component_setup.rs index e858072683..ef5dc0cc9f 100644 --- a/crates/defguard_core/src/handlers/proxy_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -1,17 +1,23 @@ use std::{convert::Infallible, time::Duration}; use axum::{ - extract::{Query, State}, + extract::{Path, 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}, + db::{ + Id, + models::{Settings, gateway::Gateway, proxy::Proxy}, + }, types::proxy::ProxyControlMessage, }; -use defguard_proto::proxy::{CertificateInfo, DerPayload, proxy_setup_client::ProxySetupClient}; +use defguard_proto::{ + gateway::gateway_setup_client::GatewaySetupClient, + proxy::{CertificateInfo, DerPayload, proxy_setup_client::ProxySetupClient}, +}; use defguard_version::{Version, client::ClientVersionInterceptor}; use futures::Stream; use reqwest::Url; @@ -23,7 +29,11 @@ use tonic::{ transport::{Certificate, ClientTlsConfig, Endpoint}, }; -use crate::{AppState, auth::AdminRole, version::MIN_PROXY_VERSION}; +use crate::{ + AppState, + auth::AdminRole, + version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, +}; const TOKEN_CLIENT_ID: &str = "Defguard Core"; const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); @@ -44,9 +54,16 @@ pub struct ProxySetupRequest { pub common_name: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct GatewaySetupRequest { + pub common_name: String, + pub ip_or_domain: String, + pub grpc_port: u16, +} + #[derive(Debug, Serialize, Copy, Clone)] #[serde(tag = "step", content = "data")] -pub enum ProxySetupStep { +pub enum SetupStep { CheckingConfiguration, CheckingAvailability, CheckingVersion, @@ -57,9 +74,9 @@ pub enum ProxySetupStep { } #[derive(Debug, Serialize)] -pub struct ProxySetupResponse { +pub struct SetupResponse { #[serde(flatten)] - pub step: ProxySetupStep, + pub step: SetupStep, pub proxy_version: Option, pub message: Option, pub logs: Option>, @@ -87,14 +104,14 @@ impl Interceptor for AuthInterceptor { } } -fn fallback_message(err: &str, last_step: ProxySetupStep) -> String { +fn fallback_message(err: &str, last_step: SetupStep) -> 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 { +fn error_message(message: &str, last_step: SetupStep, logs: Option>) -> Event { + let response = SetupResponse { step: last_step, proxy_version: None, message: Some(message.to_string()), @@ -108,8 +125,8 @@ fn error_message(message: &str, last_step: ProxySetupStep, logs: Option Event { - let response = ProxySetupResponse { +fn set_step_message(next_step: SetupStep) -> Event { + let response = SetupResponse { step: next_step, proxy_version: None, message: None, @@ -124,19 +141,19 @@ fn set_step_message(next_step: ProxySetupStep) -> Event { } struct SetupFlow { - last_step: ProxySetupStep, + last_step: SetupStep, log_rx: tokio::sync::mpsc::UnboundedReceiver, } impl SetupFlow { const fn new(log_rx: tokio::sync::mpsc::UnboundedReceiver) -> Self { Self { - last_step: ProxySetupStep::CheckingConfiguration, + last_step: SetupStep::CheckingConfiguration, log_rx, } } - fn step(&mut self, next_step: ProxySetupStep) -> Event { + fn step(&mut self, next_step: SetupStep) -> Event { self.last_step = next_step; set_step_message(next_step) } @@ -170,7 +187,7 @@ pub async fn setup_proxy_tls_stream( // Step 1: Check configuration yield Ok( - flow.step(ProxySetupStep::CheckingConfiguration) + flow.step(SetupStep::CheckingConfiguration) ); match Proxy::find_by_address_port(&appstate.pool, &request.ip_or_domain, i32::from(request.grpc_port)).await { @@ -251,7 +268,7 @@ pub async fn setup_proxy_tls_stream( // Step 2: Check availability yield Ok( - flow.step(ProxySetupStep::CheckingAvailability) + flow.step(SetupStep::CheckingAvailability) ); @@ -327,7 +344,7 @@ pub async fn setup_proxy_tls_stream( // Step 3: Check version yield Ok( - flow.step(ProxySetupStep::CheckingVersion) + flow.step(SetupStep::CheckingVersion) ); let proxy_version = response_with_metadata @@ -351,8 +368,8 @@ pub async fn setup_proxy_tls_stream( debug!("Edge proxy version {} is compatible with core version {}", proxy_version, version_clone); - let response = ProxySetupResponse { - step: ProxySetupStep::CheckingVersion, + let response = SetupResponse { + step: SetupStep::CheckingVersion, proxy_version: Some(proxy_version.to_string()), message: None, logs: None, @@ -411,7 +428,7 @@ pub async fn setup_proxy_tls_stream( let _log_task_guard = TaskGuard(log_reader_task); // Step 4: Obtain CSR - yield Ok(flow.step(ProxySetupStep::ObtainingCsr)); + yield Ok(flow.step(SetupStep::ObtainingCsr)); let Some(hostname) = url.host_str() else { yield Ok(flow.error("URL does not have a valid host")); @@ -442,7 +459,7 @@ pub async fn setup_proxy_tls_stream( debug!("Received certificate signing request from edge proxy for hostname: {}", hostname); // Step 5: Sign certificate - yield Ok(flow.step(ProxySetupStep::SigningCertificate)); + yield Ok(flow.step(SetupStep::SigningCertificate)); let settings = Settings::get_current_settings(); @@ -480,7 +497,7 @@ pub async fn setup_proxy_tls_stream( debug!("Successfully signed certificate for edge proxy"); // Step 6: Configure TLS - yield Ok(flow.step(ProxySetupStep::ConfiguringTls)); + yield Ok(flow.step(SetupStep::ConfiguringTls)); let response = DerPayload { der_data: cert.der().to_vec(), @@ -534,7 +551,381 @@ pub async fn setup_proxy_tls_stream( debug!("Edge proxy setup completed successfully"); // Step 7: Done - yield Ok(flow.step(ProxySetupStep::Done)); + yield Ok(flow.step(SetupStep::Done)); + }; + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// This is the endpoint responsible for the whole gateway 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_gateway_tls_stream( + _admin: AdminRole, + State(appstate): State, + Query(request): Query, + Path(network_id): Path, +) -> 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(SetupStep::CheckingConfiguration) + ); + + + let url_str = format!("http://{}:{}", request.ip_or_domain, request.grpc_port); + + match Gateway::find_by_url(&appstate.pool, &url_str).await { + Ok(Some(gateway)) => { + yield Ok(flow.error(&format!("A Gateway with url {} is already registered with hostname \"{:?}\".", url_str, gateway.hostname))); + return; + } + Ok(None) => { + debug!("Verified no existing Gateway registration for {}:{}", request.ip_or_domain, request.grpc_port); + }, + Err(e) => { + yield Ok(flow.error(&format!("Failed to query existing Gateway: {e}"))); + return; + } + } + + let url = match Url::parse(&url_str) { + Ok(u) => u, + Err(e) => { + yield Ok(flow.error(&format!("Invalid URL: {e}"))); + return; + } + }; + + debug!("Successfully validated Gateway address: {}", url_str); + + let endpoint = match Endpoint::from_shared(url.to_string()) { + 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 Gateway 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(SetupStep::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 Gateway authentication"); + + let version_interceptor = ClientVersionInterceptor::new(version); + let auth_interceptor = AuthInterceptor::new(token); + + let mut client = GatewaySetupClient::with_interceptor( + endpoint.connect_lazy(), + move |mut req: Request<()>| { + req = version_interceptor.clone().call(req)?; + auth_interceptor.clone().call(req) + } + ); + + debug!("Initiating connection to edge Gateway 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 Gateway at {}:{}: {}. This may indicate that the Gateway is already configured with TLS. Please check if the Gateway has already been set up.", + request.ip_or_domain, request.grpc_port, e + ))); + } else { + yield Ok(flow.error(&format!( + "Failed to connect to Gateway at {}:{}. Please ensure the address and port are correct and that the Gateway is running.", + request.ip_or_domain, request.grpc_port + ))); + } + } + _ => { + yield Ok(flow.error(&format!("Failed to connect to Gateway: {e}"))); + } + } + return; + } + Err(_) => { + yield Ok(flow.error(&format!( + "Connection to Gateway at {}:{} timed out after 10 seconds.", + request.ip_or_domain, request.grpc_port + ))); + return; + } + }; + + debug!("Successfully connected to Gateway"); + + // Step 3: Check version + yield Ok( + flow.step(SetupStep::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_GATEWAY_VERSION { + yield Ok(flow.error(&format!( + "Gateway version {proxy_version} is older than core version {version_clone}. Please update the edge component.", + ))); + return; + } + + debug!("Gateway version {} is compatible with core version {}", proxy_version, version_clone); + + let response = SetupResponse { + step: SetupStep::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 Gateway 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(SetupStep::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(defguard_proto::gateway::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 Gateway for hostname: {}", hostname); + + // Step 5: Sign certificate + yield Ok(flow.step(SetupStep::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 Gateway"); + + // Step 6: Configure TLS + yield Ok(flow.step(SetupStep::ConfiguringTls)); + + let response = defguard_proto::gateway::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 Gateway"); + + 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 gateway = Gateway::new( + network_id, + url_str, + request.common_name, + ); + + gateway.has_certificate = true; + gateway.certificate_expiry = Some(expiry); + + if let Err(err) = gateway.save(&appstate.pool).await { + yield Ok(flow.error(&format!("Failed to save Gateway to database: {err}"))); + return; + }; + + debug!("Gateway setup completed successfully"); + + // Step 7: Done + yield Ok(flow.step(SetupStep::Done)); }; Sse::new(stream).keep_alive(KeepAlive::default()) diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 811ba9a6f6..f9c1d197f7 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -31,6 +31,7 @@ pub(crate) mod activity_log; pub(crate) mod app_info; pub(crate) mod auth; pub mod ca; +pub(crate) mod component_setup; pub(crate) mod forward_auth; pub(crate) mod group; pub mod mail; @@ -38,7 +39,6 @@ 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; diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index fc9755c42d..01e7d2171e 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -598,27 +598,6 @@ pub(crate) struct GatewayData { url: String, } -/// Add gateway (POST). -pub(crate) async fn add_gateway( - Path(network_id): Path, - _role: AdminRole, - State(appstate): State, - Json(data): Json, -) -> ApiResult { - debug!("Adding gateway in network {network_id}"); - - let gateway = Gateway::new(network_id, data.url) - .save(&appstate.pool) - .await?; - - info!("Added gateway in network {network_id}"); - - Ok(ApiResponse { - json: json!(GatewayInfo::from(gateway)), - status: StatusCode::CREATED, - }) -} - /// Change gateway (PUT). pub(crate) async fn change_gateway( Path((network_id, gateway_id)): Path<(Id, Id)>, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 7c7eff5661..d19a112d83 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -37,13 +37,13 @@ use events::ApiEvent; use handlers::{ activity_log::get_activity_log_events, auth::disable_user_mfa, + component_setup::setup_proxy_tls_stream, group::{bulk_assign_to_groups, list_groups_info}, network_devices::{ add_network_device, check_ip_availability, download_network_device_config, 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, @@ -115,6 +115,7 @@ use crate::{ webauthn_start, }, ca::create_ca, + component_setup::setup_gateway_tls_stream, forward_auth::forward_auth, group::{ add_group_member, create_group, delete_group, get_group, list_groups, modify_group, @@ -146,11 +147,10 @@ use crate::{ add_webhook, change_enabled, change_webhook, delete_webhook, get_webhook, list_webhooks, }, wireguard::{ - add_device, add_gateway, add_user_devices, change_gateway, create_network, - create_network_token, delete_device, delete_network, devices_stats, download_config, - gateway_status, get_device, import_network, list_devices, list_networks, - list_user_devices, modify_device, modify_network, network_details, network_stats, - remove_gateway, + add_device, add_user_devices, change_gateway, create_network, create_network_token, + delete_device, delete_network, devices_stats, download_config, gateway_status, + get_device, import_network, list_devices, list_networks, list_user_devices, + modify_device, modify_network, network_details, network_stats, remove_gateway, }, worker::{create_job, create_worker_token, job_status, list_workers, remove_worker}, }, @@ -501,8 +501,12 @@ pub fn build_webapp( .delete(delete_network) .get(network_details), ) + // Gateway adding (uses SSE) + .route( + "/network/{network_id}/gateways/setup", + get(setup_gateway_tls_stream), + ) .route("/network/{network_id}/gateways", get(gateway_status)) - .route("/network/{network_id}/gateways", post(add_gateway)) .route( "/network/{network_id}/gateways/{gateway_id}", put(change_gateway).delete(remove_gateway), diff --git a/crates/defguard_version/src/tracing.rs b/crates/defguard_version/src/tracing.rs index bcf33fecdf..69fd29a641 100644 --- a/crates/defguard_version/src/tracing.rs +++ b/crates/defguard_version/src/tracing.rs @@ -91,10 +91,9 @@ use tracing_subscriber::{ }, layer::{Context, SubscriberExt}, registry::LookupSpan, - util::SubscriberInitExt, }; -use crate::{ComponentInfo, DefguardComponent, DefguardVersionError, SystemInfo}; +use crate::{ComponentInfo, DefguardComponent, SystemInfo}; /// Container for version information extracted from tracing span hierarchy. /// @@ -404,7 +403,7 @@ impl tracing::field::Visit for FieldFilterVisitor<'_> { } } -/// Initializes tracing with custom formatter that conditionally displays version information. +/// Adds a custom formatter that conditionally displays version information to a given subscriber. /// /// The formatter will: /// - For ERROR level logs: display own and remote component version and system-info @@ -421,10 +420,22 @@ impl tracing::field::Visit for FieldFilterVisitor<'_> { /// /// # Examples /// ``` -/// defguard_version::tracing::init(defguard_version::Version::new(1, 5, 0), "info"); +/// let subscriber = tracing_subscriber::registry(); +/// defguard_version::tracing::with_version_formatter( +/// &defguard_version::Version::new(1, 5, 0), +/// "info", +/// subscriber, +/// ); /// ``` -pub fn init(own_version: crate::Version, log_level: &str) -> Result<(), DefguardVersionError> { - tracing_subscriber::registry() +pub fn with_version_formatters( + own_version: &crate::Version, + log_level: &str, + subscriber: S, +) -> impl tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a> + Send + Sync +where + S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a> + Send + Sync, +{ + subscriber .with( EnvFilter::try_from_default_env() .unwrap_or_else(|_| format!("{log_level},h2=info").into()), @@ -434,12 +445,9 @@ pub fn init(own_version: crate::Version, log_level: &str) -> Result<(), Defguard tracing_subscriber::fmt::layer() .with_ansi(true) .event_format(VersionSuffixFormat::new( - own_version, + crate::Version::new(own_version.major, own_version.minor, own_version.patch), Format::default().with_ansi(true), )) .fmt_fields(VersionFilteredFields), ) - .init(); - - Ok(()) } diff --git a/migrations/20260127085637_[2.0.0]_gateway_common_name.down.sql b/migrations/20260127085637_[2.0.0]_gateway_common_name.down.sql new file mode 100644 index 0000000000..3cde267aba --- /dev/null +++ b/migrations/20260127085637_[2.0.0]_gateway_common_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE gateway DROP COLUMN name; diff --git a/migrations/20260127085637_[2.0.0]_gateway_common_name.up.sql b/migrations/20260127085637_[2.0.0]_gateway_common_name.up.sql new file mode 100644 index 0000000000..9d1887dd3a --- /dev/null +++ b/migrations/20260127085637_[2.0.0]_gateway_common_name.up.sql @@ -0,0 +1 @@ +ALTER TABLE gateway ADD COLUMN name TEXT NOT NULL; diff --git a/proto b/proto index d01bfabe4e..fdbe98caa9 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit d01bfabe4e4f7f419de3682020e708f5861530da +Subproject commit fdbe98caa9413b626833da210b5b588b287bb146 diff --git a/web/messages/en/gateway_wizard.json b/web/messages/en/gateway_wizard.json new file mode 100644 index 0000000000..7816574338 --- /dev/null +++ b/web/messages/en/gateway_wizard.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "gateway_setup_confirmation_title": "Gateway component configured successfully.", + "gateway_setup_confirmation_subtitle": "Your VPN location is now fully configured.", + "gateway_setup_add_multiple_gateways_title": "Add multiple Gateways", + "gateway_setup_add_multiple_gateways_subtitle": "Each location can include multiple Gateways. You may add another component at this stage or return and do it later.", + "gateway_setup_controls_add_another_gateway": "Add another Gateway", + "gateway_setup_controls_go_to_locations": "Go to locations", + "gateway_setup_page_title": "Configure Gateway component", + "gateway_setup_page_subtitle": "To activate localization, make sure at least one Gateway is connected.", + "gateway_setup_welcome_title": "Deploy your Gateway component", + "gateway_setup_welcome_subtitle": "Welcome to the Gateway component Setup Wizard. This guide will help you deploy the Gateway required for secure and reliable VPN communication.", + "gateway_setup_welcome_deploy_title": "Deploy Gateway component first", + "gateway_setup_welcome_deploy_subtitle": "Make sure your Gateway 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.", + "gateway_setup_welcome_docs_text": "Before installation, we recommend reading our documentation to understand the system architecture and core components.", + "gateway_setup_welcome_image_alt": "Welcome to Gateway component setup wizard", + "gateway_setup_controls_configure": "Configure gateway", + "gateway_setup_step_gateway_component_label": "Configure Gateway component", + "gateway_setup_step_gateway_component_description": "Set up your VPN proxy quickly and ensure secure, optimized traffic flow for your users.", + "gateway_setup_step_gateway_adaptation_label": "Gateway Adoption", + "gateway_setup_step_gateway_adaptation_description": "Review the system's checks and see if any issues need attention before deployment.", + "gateway_setup_step_confirmation_label": "Configuration confirmation", + "gateway_setup_step_confirmation_description": "Your configuration was successful. You're all set.", + "gateway_setup_component_label_common_name": "Common Name", + "gateway_setup_component_label_ip_or_domain": "IP or Domain", + "gateway_setup_component_label_grpc_port": "gRPC Port", + "gateway_setup_component_error_common_name_required": "Common Name is required", + "gateway_setup_component_error_ip_or_domain_required": "IP or Domain is required", + "gateway_setup_component_error_grpc_port_required": "gRPC Port is required", + "gateway_setup_component_error_grpc_port_max": "gRPC Port must be less than 65536", + "gateway_setup_component_controls_back": "Back", + "gateway_setup_component_controls_submit": "Adopt Gateway component", + "gateway_setup_adaptation_checking_configuration": "Checking provided Gateway component configuration", + "gateway_setup_adaptation_checking_availability": "Checking if Gateway is available at: {ip_or_domain}:{grpc_port}", + "gateway_setup_adaptation_checking_version": "Checking Gateway version", + "gateway_setup_adaptation_checking_version_with_value": "Checking Gateway version: {gatewayVersion}", + "gateway_setup_adaptation_obtaining_csr": "Obtaining Certificate Signing Request", + "gateway_setup_adaptation_signing_certificate": "Signing Certificate", + "gateway_setup_adaptation_configuring_tls": "Configuring TLS communication", + "gateway_setup_adaptation_error_default": "An error occurred during setup.", + "gateway_setup_adaptation_error_log_title": "Error log", + "gateway_setup_adaptation_controls_retry": "Retry", + "gateway_setup_adaptation_controls_back": "Back", + "gateway_setup_adaptation_controls_continue": "Continue" +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index ca115a53cf..a080a2894b 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -19,7 +19,8 @@ "./messages/{locale}/openid.json", "./messages/{locale}/activity.json", "./messages/{locale}/edge_wizard.json", - "./messages/{locale}/settings.json" + "./messages/{locale}/settings.json", + "./messages/{locale}/gateway_wizard.json" ] } } diff --git a/web/src/pages/EdgeSetupPage/steps/useSSEController.tsx b/web/src/hooks/useSSEController.tsx similarity index 82% rename from web/src/pages/EdgeSetupPage/steps/useSSEController.tsx rename to web/src/hooks/useSSEController.tsx index da993a82bc..a46fe8c861 100644 --- a/web/src/pages/EdgeSetupPage/steps/useSSEController.tsx +++ b/web/src/hooks/useSSEController.tsx @@ -1,11 +1,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { SSEHookOptions } from './types'; + +// 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; +} // 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, + params: Record, options: SSEHookOptions = {}, ) { const eventSourceRef = useRef(null); diff --git a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx index bb0d4b03b7..b7fd4b3244 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx @@ -13,7 +13,7 @@ import { ModalControls } from '../../../shared/defguard-ui/components/ModalContr import { Radio } from '../../../shared/defguard-ui/components/Radio/Radio'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; -import { useLocationsPageStore } from '../../LocationsPage/hooks/useLocationsPage'; +import { useGatewayWizardStore } from '../../GatewaySetupPage/useGatewayWizardStore'; import actionCardImage from '../assets/gateway-setup-action-card.png'; import { AddLocationPageStep } from '../types'; import { useAddLocationStore } from '../useAddLocationStore'; @@ -32,15 +32,19 @@ export const AddLocationFirewallStep = () => { }, onSuccess: ({ data }) => { if (showGateway) { - useLocationsPageStore.setState({ - networkGatewayStartup: data.id, + useGatewayWizardStore.getState().start({ network_id: data.id }); + navigate({ to: '/gateway-wizard', replace: true }).then(() => { + setTimeout(() => { + useAddLocationStore.getState().reset(); + }, 100); + }); + } else { + navigate({ to: '/locations', replace: true }).then(() => { + setTimeout(() => { + useAddLocationStore.getState().reset(); + }, 100); }); } - navigate({ to: '/locations', replace: true }).then(() => { - setTimeout(() => { - useAddLocationStore.getState().reset(); - }, 100); - }); }, }); diff --git a/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx b/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx index 7e355393b3..d8a8f7c48e 100644 --- a/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx +++ b/web/src/pages/EdgeSetupPage/EdgeSetupPage.tsx @@ -81,10 +81,10 @@ export const EdgeSetupPage = () => { { - useEdgeWizardStore.getState().reset(); - navigate({ - to: '/settings', - replace: true, + navigate({ to: '/vpn-overview', replace: true }).then(() => { + setTimeout(() => { + useEdgeWizardStore.getState().reset(); + }, 100); }); }} subtitle={m.edge_setup_page_subtitle()} diff --git a/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx index 0e976d4db4..dedc5ebe9b 100644 --- a/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx +++ b/web/src/pages/EdgeSetupPage/steps/SetupConfirmationStep.tsx @@ -17,8 +17,11 @@ export const SetupConfirmationStep = () => { }; const handleFinish = () => { - useEdgeWizardStore.getState().reset(); - navigate({ to: '/vpn-overview' }); + navigate({ to: '/vpn-overview', replace: true }).then(() => { + setTimeout(() => { + useEdgeWizardStore.getState().reset(); + }, 100); + }); }; return ( diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx index 408bab60fa..2ddc07a0c2 100644 --- a/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react'; +import { useSSEController } from '../../../hooks/useSSEController'; import { m } from '../../../paraglide/messages'; import { Controls } from '../../../shared/components/Controls/Controls'; import { LoadingStep } from '../../../shared/components/LoadingStep/LoadingStep'; @@ -11,7 +12,6 @@ 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); @@ -26,7 +26,7 @@ export const SetupEdgeAdaptationStep = () => { currentStep: event.step, isComplete: event.step === 'Done', isProcessing: event.step !== 'Done' && !event.error, - proxyVersion: event.proxy_version ?? null, + proxyVersion: event.version ?? null, errorMessage: event.error ? event.message || m.edge_setup_adaptation_error_default() : null, @@ -127,7 +127,7 @@ export const SetupEdgeAdaptationStep = () => { [edgeAdaptationState.errorMessage, edgeAdaptationState.currentStep], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: mount only + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount useEffect(() => { resetEdgeAdaptationState(); sse.start(); diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx index c40c716871..a24762a5f9 100644 --- a/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx @@ -42,10 +42,10 @@ export const SetupEdgeComponentStep = () => { }; const handleBack = () => { - useEdgeWizardStore.getState().reset(); - navigate({ - to: '/edge-wizard', - replace: true, + navigate({ to: '/edge-wizard', replace: true }).then(() => { + setTimeout(() => { + useEdgeWizardStore.getState().reset(); + }, 100); }); }; diff --git a/web/src/pages/EdgeSetupPage/steps/style.scss b/web/src/pages/EdgeSetupPage/steps/style.scss index 10e13b988e..f8331ae599 100644 --- a/web/src/pages/EdgeSetupPage/steps/style.scss +++ b/web/src/pages/EdgeSetupPage/steps/style.scss @@ -8,4 +8,9 @@ .controls { padding-top: 0; } + + .code-card { + white-space: pre-wrap; + overflow-wrap: anywhere; + } } diff --git a/web/src/pages/EdgeSetupPage/steps/types.ts b/web/src/pages/EdgeSetupPage/steps/types.ts index b19078c105..25f7b28fae 100644 --- a/web/src/pages/EdgeSetupPage/steps/types.ts +++ b/web/src/pages/EdgeSetupPage/steps/types.ts @@ -1,6 +1,6 @@ export type SetupEvent = { step: SetupStepId; - proxy_version?: string; + version?: string; message?: string; logs?: string[]; error: boolean; @@ -19,11 +19,3 @@ export type SetupStepId = | '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/useEdgeWizardStore.tsx b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx index adf2dc1992..4301ede973 100644 --- a/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx +++ b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx @@ -76,7 +76,7 @@ export const useEdgeWizardStore = create()( })), }), { - name: 'setup-wizard-store', + name: 'edge-wizard-store', storage: createJSONStorage(() => sessionStorage), partialize: (state) => omit(state, [ diff --git a/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx b/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx new file mode 100644 index 0000000000..769f21c1f2 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/GatewaySetupPage.tsx @@ -0,0 +1,98 @@ +import './style.scss'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +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 { ThemeSpacing } from '../../shared/defguard-ui/types'; +import welcomeImage from './assets/welcome_image.svg'; +import { SetupConfirmationStep } from './steps/SetupConfirmationStep'; +import { SetupGatewayAdaptationStep } from './steps/SetupGatewayAdaptationStep'; +import { SetupGatewayComponentStep } from './steps/SetupGatewayComponentStep'; +import { GatewaySetupStep, type GatewaySetupStepValue } from './types'; +import { useGatewayWizardStore } from './useGatewayWizardStore'; + +export const GatewaySetupPage = () => { + const activeStep = useGatewayWizardStore((s) => s.activeStep); + const showWelcome = useGatewayWizardStore((s) => s.showWelcome); + const setShowWelcome = useGatewayWizardStore((s) => s.setShowWelcome); + const navigate = useNavigate(); + + const stepsConfig = useMemo( + (): Record => ({ + gatewayComponent: { + id: GatewaySetupStep.GatewayComponent, + order: 1, + label: m.gateway_setup_step_gateway_component_label(), + description: m.gateway_setup_step_gateway_component_description(), + }, + gatewayAdaptation: { + id: GatewaySetupStep.GatewayAdaptation, + order: 2, + label: m.gateway_setup_step_gateway_adaptation_label(), + description: m.gateway_setup_step_gateway_adaptation_description(), + }, + confirmation: { + id: GatewaySetupStep.Confirmation, + order: 3, + label: m.gateway_setup_step_confirmation_label(), + description: m.gateway_setup_step_confirmation_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + gatewayComponent: , + gatewayAdaptation: , + confirmation: , + }), + [], + ); + + const WelcomePageContent = () => ( + <> + +
+ +
+ + ); + + return ( + { + navigate({ to: '/locations', replace: true }).then(() => { + setTimeout(() => { + useGatewayWizardStore.getState().reset(); + }, 100); + }); + }} + subtitle={m.gateway_setup_page_subtitle()} + title={m.gateway_setup_page_title()} + steps={stepsConfig} + id="setup-wizard" + showWelcome={showWelcome} + welcomePageConfig={{ + title: m.gateway_setup_welcome_title(), + subtitle: m.gateway_setup_welcome_subtitle(), + content: , + docsLink: 'https://docs.defguard.net/edge-component/deployment', + docsText: m.gateway_setup_welcome_docs_text(), + media: {m.gateway_setup_welcome_image_alt()}, + }} + > + {stepsComponents[activeStep]} + + ); +}; diff --git a/web/src/pages/GatewaySetupPage/assets/add_more.svg b/web/src/pages/GatewaySetupPage/assets/add_more.svg new file mode 100644 index 0000000000..36badeb389 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/assets/add_more.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/GatewaySetupPage/assets/deploy.svg b/web/src/pages/GatewaySetupPage/assets/deploy.svg new file mode 100644 index 0000000000..346353af49 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/assets/deploy.svg @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/GatewaySetupPage/assets/file_icon.png b/web/src/pages/GatewaySetupPage/assets/file_icon.png new file mode 100644 index 0000000000..f0cafdc06a Binary files /dev/null and b/web/src/pages/GatewaySetupPage/assets/file_icon.png differ diff --git a/web/src/pages/GatewaySetupPage/assets/welcome_image.svg b/web/src/pages/GatewaySetupPage/assets/welcome_image.svg new file mode 100644 index 0000000000..c265621aaf --- /dev/null +++ b/web/src/pages/GatewaySetupPage/assets/welcome_image.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx new file mode 100644 index 0000000000..30e4983335 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx @@ -0,0 +1,51 @@ +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 { useGatewayWizardStore } from '../useGatewayWizardStore'; + +export const SetupConfirmationStep = () => { + const navigate = useNavigate(); + + const handleBack = () => { + useGatewayWizardStore.getState().reset(); + }; + + const handleFinish = () => { + navigate({ to: '/locations', replace: true }).then(() => { + setTimeout(() => { + useGatewayWizardStore.getState().reset(); + }, 100); + }); + }; + + return ( + +

{m.edge_setup_confirmation_title()}

+ +

{m.edge_setup_confirmation_subtitle()}

+ + + +
+ ); +}; diff --git a/web/src/pages/GatewaySetupPage/steps/SetupGatewayAdaptationStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupGatewayAdaptationStep.tsx new file mode 100644 index 0000000000..222311b176 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/steps/SetupGatewayAdaptationStep.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSSEController } from '../../../hooks/useSSEController'; +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 { GatewaySetupStep } from '../types'; +import { useGatewayWizardStore } from '../useGatewayWizardStore'; +import type { SetupEvent, SetupStep, SetupStepId } from './types'; + +export const SetupGatewayAdaptationStep = () => { + const setActiveStep = useGatewayWizardStore((s) => s.setActiveStep); + const gatewayComponentWizardStore = useGatewayWizardStore((s) => s); + const gatewayAdaptationState = useGatewayWizardStore((s) => s.gatewayAdaptationState); + const setGatewayAdaptationState = useGatewayWizardStore( + (s) => s.setGatewayAdaptationState, + ); + const resetGatewayAdaptationState = useGatewayWizardStore( + (s) => s.resetGatewayAdaptationState, + ); + + const handleEvent = useCallback( + (event: SetupEvent) => { + setGatewayAdaptationState({ + currentStep: event.step, + isComplete: event.step === 'Done', + isProcessing: event.step !== 'Done' && !event.error, + gatewayVersion: event.version ?? null, + errorMessage: event.error + ? event.message || m.edge_setup_adaptation_error_default() + : null, + gatewayLogs: event.logs && event.logs.length > 0 ? [...event.logs] : [], + }); + }, + [setGatewayAdaptationState], + ); + + const sse = useSSEController( + `/api/v1/network/${gatewayComponentWizardStore.network_id}/gateways/setup`, + { + ip_or_domain: gatewayComponentWizardStore.ip_or_domain, + grpc_port: gatewayComponentWizardStore.grpc_port, + common_name: gatewayComponentWizardStore.common_name, + network_id: gatewayComponentWizardStore.network_id, + }, + { + onMessage: handleEvent, + }, + ); + + const handleBack = () => { + useGatewayWizardStore.getState().resetGatewayAdaptationState(); + setActiveStep(GatewaySetupStep.GatewayComponent); + }; + + const handleNext = () => { + setActiveStep(GatewaySetupStep.Confirmation); + }; + + const steps: SetupStep[] = useMemo( + () => [ + { + id: 'CheckingConfiguration', + title: m.gateway_setup_adaptation_checking_configuration(), + }, + { + id: 'CheckingAvailability', + title: m.gateway_setup_adaptation_checking_availability({ + ip_or_domain: gatewayComponentWizardStore.ip_or_domain, + grpc_port: String(gatewayComponentWizardStore.grpc_port), + }), + }, + { + id: 'CheckingVersion', + title: gatewayAdaptationState.gatewayVersion + ? m.gateway_setup_adaptation_checking_version_with_value({ + gatewayVersion: gatewayAdaptationState.gatewayVersion, + }) + : m.gateway_setup_adaptation_checking_version(), + }, + { + id: 'ObtainingCsr', + title: m.gateway_setup_adaptation_obtaining_csr(), + }, + { + id: 'SigningCertificate', + title: m.gateway_setup_adaptation_signing_certificate(), + }, + { + id: 'ConfiguringTls', + title: m.gateway_setup_adaptation_configuring_tls(), + }, + ], + [gatewayComponentWizardStore, gatewayAdaptationState.gatewayVersion], + ); + + const stepDone = useCallback( + (stepId: SetupStepId): boolean => { + const stepIndex = steps.findIndex((step) => step.id === stepId); + const currentStepIndex = gatewayAdaptationState.currentStep + ? steps.findIndex((step) => step.id === gatewayAdaptationState.currentStep) + : -1; + return stepIndex < currentStepIndex || gatewayAdaptationState.isComplete; + }, + [gatewayAdaptationState.isComplete, gatewayAdaptationState.currentStep, steps], + ); + + const stepLoading = useCallback( + (stepId: SetupStepId): boolean => { + return ( + gatewayAdaptationState.isProcessing && + gatewayAdaptationState.currentStep === stepId + ); + }, + [gatewayAdaptationState.isProcessing, gatewayAdaptationState.currentStep], + ); + + const stepError = useCallback( + (stepId: SetupStepId): string | null => { + if ( + gatewayAdaptationState.errorMessage && + gatewayAdaptationState.currentStep === stepId + ) { + return gatewayAdaptationState.errorMessage; + } + return null; + }, + [gatewayAdaptationState.errorMessage, gatewayAdaptationState.currentStep], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount + useEffect(() => { + resetGatewayAdaptationState(); + sse.start(); + + return () => { + sse.stop(); + }; + }, []); + + return ( + +
+ {steps.map((step, index) => ( + + {gatewayAdaptationState.gatewayLogs.length > 0 ? ( + <> + + + + ) : null} + +
+
+
+
+ ))} +
+ +
+ ); +}; diff --git a/web/src/pages/GatewaySetupPage/steps/SetupGatewayComponentStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupGatewayComponentStep.tsx new file mode 100644 index 0000000000..3674d57f01 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/steps/SetupGatewayComponentStep.tsx @@ -0,0 +1,139 @@ +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 { GatewaySetupStep } from '../types'; +import { useGatewayWizardStore } from '../useGatewayWizardStore'; +import './style.scss'; +import { validateIpOrDomain } from '../../../shared/validators'; + +type FormFields = StoreValues; + +type StoreValues = { + common_name: string; + ip_or_domain: string; + grpc_port: number; +}; + +export const SetupGatewayComponentStep = () => { + const setActiveStep = useGatewayWizardStore((s) => s.setActiveStep); + const navigate = useNavigate(); + const defaultValues = useGatewayWizardStore( + useShallow( + (s): FormFields => ({ + common_name: s.common_name, + ip_or_domain: s.ip_or_domain, + grpc_port: s.grpc_port, + }), + ), + ); + + const handleNext = () => { + form.handleSubmit(); + }; + + const handleBack = () => { + navigate({ to: '/locations', replace: true }).then(() => { + setTimeout(() => { + useGatewayWizardStore.getState().reset(); + }, 100); + }); + }; + + 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()), + }), + [], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useGatewayWizardStore.setState({ + ...value, + }); + setActiveStep(GatewaySetupStep.GatewayAdaptation); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + +
+ +
+ ); +}; diff --git a/web/src/pages/GatewaySetupPage/steps/style.scss b/web/src/pages/GatewaySetupPage/steps/style.scss new file mode 100644 index 0000000000..f8331ae599 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/steps/style.scss @@ -0,0 +1,16 @@ +.wizard-card { + .modal-controls > .buttons { + display: flex; + flex-grow: 1; + justify-content: space-between; + } + + .controls { + padding-top: 0; + } + + .code-card { + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} diff --git a/web/src/pages/GatewaySetupPage/steps/types.ts b/web/src/pages/GatewaySetupPage/steps/types.ts new file mode 100644 index 0000000000..25f7b28fae --- /dev/null +++ b/web/src/pages/GatewaySetupPage/steps/types.ts @@ -0,0 +1,21 @@ +export type SetupEvent = { + step: SetupStepId; + 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'; diff --git a/web/src/pages/GatewaySetupPage/style.scss b/web/src/pages/GatewaySetupPage/style.scss new file mode 100644 index 0000000000..9ca97f6e8c --- /dev/null +++ b/web/src/pages/GatewaySetupPage/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/GatewaySetupPage/types.ts b/web/src/pages/GatewaySetupPage/types.ts new file mode 100644 index 0000000000..f4ae6bcdf0 --- /dev/null +++ b/web/src/pages/GatewaySetupPage/types.ts @@ -0,0 +1,8 @@ +export const GatewaySetupStep = { + GatewayComponent: 'gatewayComponent', + GatewayAdaptation: 'gatewayAdaptation', + Confirmation: 'confirmation', +} as const; + +export type GatewaySetupStepValue = + (typeof GatewaySetupStep)[keyof typeof GatewaySetupStep]; diff --git a/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx b/web/src/pages/GatewaySetupPage/useGatewayWizardStore.tsx new file mode 100644 index 0000000000..bac51baf1f --- /dev/null +++ b/web/src/pages/GatewaySetupPage/useGatewayWizardStore.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 { GatewaySetupStep, type GatewaySetupStepValue } from './types'; + +type GatewayAdaptationState = { + isProcessing: boolean; + isComplete: boolean; + currentStep: SetupStepId | null; + errorMessage: string | null; + gatewayVersion: string | null; + gatewayLogs: string[]; +}; + +type StoreValues = { + activeStep: GatewaySetupStepValue; + showWelcome: boolean; + common_name: string; + ip_or_domain: string; + grpc_port: number; + network_id: number | null; + gatewayAdaptationState: GatewayAdaptationState; +}; + +type StoreMethods = { + reset: () => void; + start: (values?: Partial) => void; + setActiveStep: (step: GatewaySetupStepValue) => void; + setShowWelcome: (show: boolean) => void; + updateValues: (values: Partial) => void; + resetGatewayAdaptationState: () => void; + setGatewayAdaptationState: (state: GatewayAdaptationState) => void; +}; + +const gatewayAdaptationStateDefaults: GatewayAdaptationState = { + isProcessing: false, + isComplete: false, + currentStep: null, + errorMessage: null, + gatewayVersion: null, + gatewayLogs: [], +}; + +const defaults: StoreValues = { + activeStep: GatewaySetupStep.GatewayComponent, + showWelcome: true, + common_name: '', + ip_or_domain: '', + grpc_port: 50066, + network_id: null, + gatewayAdaptationState: gatewayAdaptationStateDefaults, +}; + +export const useGatewayWizardStore = 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), + resetGatewayAdaptationState: () => + set(() => ({ + gatewayAdaptationState: { ...gatewayAdaptationStateDefaults }, + })), + setGatewayAdaptationState: (state: Partial) => + set((s) => ({ + gatewayAdaptationState: { ...s.gatewayAdaptationState, ...state }, + })), + }), + { + name: 'gateway-wizard-store', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => + omit(state, [ + 'reset', + 'start', + 'setActiveStep', + 'updateValues', + 'setShowWelcome', + 'resetEdgeAdaptationState', + 'setEdgeAdaptationState', + ]), + }, + ), +); diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx index ed88e04936..6e1dd4bfee 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx @@ -6,7 +6,6 @@ import { useMemo, useState } from 'react'; import api from '../../shared/api/api'; import type { LocationDevicesStats } from '../../shared/api/types'; import { GatewaysStatusBadge } from '../../shared/components/GatewaysStatusBadge/GatewaysStatusBadge'; -import { GatewaySetupModal } from '../../shared/components/modals/GatewaySetupModal/GatewaySetupModal'; import { OverviewPeriodSelect } from '../../shared/components/OverviewPeriodSelect/OverviewPeriodSelect'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; @@ -60,39 +59,34 @@ export const LocationOverviewPage = () => { }); return ( - <> - - -
-
-
-

{location.name}

- {isPresent(gateways) && } -
-
- { - navigate({ - search: { - period: value, - }, - }); - }} - /> -
+ + +
+
+
+

{location.name}

+ {isPresent(gateways) && } +
+
+ { + navigate({ + search: { + period: value, + }, + }); + }} + />
- {isPresent(locationStats) && ( - - )}
- - {isPresent(locationDevicesStats) && ( - + {isPresent(locationStats) && ( + )} - - - +
+ + {isPresent(locationDevicesStats) && } +
); }; diff --git a/web/src/pages/LocationsOverviewPage/LocationsOverviewPage.tsx b/web/src/pages/LocationsOverviewPage/LocationsOverviewPage.tsx index 6334691605..6c4060834d 100644 --- a/web/src/pages/LocationsOverviewPage/LocationsOverviewPage.tsx +++ b/web/src/pages/LocationsOverviewPage/LocationsOverviewPage.tsx @@ -2,7 +2,6 @@ import './style.scss'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate, useSearch } from '@tanstack/react-router'; import api from '../../shared/api/api'; -import { GatewaySetupModal } from '../../shared/components/modals/GatewaySetupModal/GatewaySetupModal'; import { OverviewPeriodSelect } from '../../shared/components/OverviewPeriodSelect/OverviewPeriodSelect'; import { Page } from '../../shared/components/Page/Page'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; @@ -34,55 +33,52 @@ export const LocationsOverviewPage = () => { }); return ( - <> - - -
-

Dashboard

-
- { - navigate({ - from: '/vpn-overview', - search: { - period: value, - }, - }); - }} - period={period} - /> -
+ + +
+

Dashboard

+
+ { + navigate({ + from: '/vpn-overview', + search: { + period: value, + }, + }); + }} + period={period} + />
- -
    - {isPresent(allStats) && ( -
  • - -
    -

    All locations summary

    -
    -
    -
  • - )} - {locations.map((location) => ( -
  • - -
  • - ))} -
- - - +
+ +
    + {isPresent(allStats) && ( +
  • + +
    +

    All locations summary

    +
    +
    +
  • + )} + {locations.map((location) => ( +
  • + +
  • + ))} +
+
); }; diff --git a/web/src/pages/LocationsPage/LocationsPage.tsx b/web/src/pages/LocationsPage/LocationsPage.tsx index 4e8df36a73..983a75ccc8 100644 --- a/web/src/pages/LocationsPage/LocationsPage.tsx +++ b/web/src/pages/LocationsPage/LocationsPage.tsx @@ -4,7 +4,6 @@ import { LocationsTable } from './components/LocationsTable'; import './style.scss'; import { useEffect } from 'react'; import api from '../../shared/api/api'; -import { GatewaySetupModal } from '../../shared/components/modals/GatewaySetupModal/GatewaySetupModal'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; @@ -39,7 +38,6 @@ export const LocationsPage = () => { - ); diff --git a/web/src/pages/LocationsPage/components/LocationsTable.tsx b/web/src/pages/LocationsPage/components/LocationsTable.tsx index 5c19b7a8e2..c07708f055 100644 --- a/web/src/pages/LocationsPage/components/LocationsTable.tsx +++ b/web/src/pages/LocationsPage/components/LocationsTable.tsx @@ -28,6 +28,7 @@ import { ThemeSpacing, ThemeVariable } from '../../../shared/defguard-ui/types'; import { openModal } from '../../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../shared/hooks/modalControls/modalTypes'; import { tableSortingFns } from '../../../shared/utils/dateSortingFn'; +import { useGatewayWizardStore } from '../../GatewaySetupPage/useGatewayWizardStore'; type Props = { locations: NetworkLocation[]; @@ -201,10 +202,9 @@ export const LocationsTable = ({ locations }: Props) => { icon: 'network-settings', text: 'Gateway setup', onClick: async () => { - const { data } = await api.location.getGatewayToken(row.id); - openModal(ModalName.GatewaySetup, { - data: data, - networkId: row.id, + useGatewayWizardStore.getState().start({ network_id: row.id }); + navigate({ + to: '/gateway-wizard', }); }, }, diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 18ed878a26..9999e927cb 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 AuthorizedWizardGatewayWizardRouteImport } from './routes/_authorized/_wizard/gateway-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' @@ -136,6 +137,12 @@ const AuthorizedWizardSetupWizardRoute = path: '/setup-wizard', getParentRoute: () => AuthorizedRoute, } as any) +const AuthorizedWizardGatewayWizardRoute = + AuthorizedWizardGatewayWizardRouteImport.update({ + id: '/_wizard/gateway-wizard', + path: '/gateway-wizard', + getParentRoute: () => AuthorizedRoute, + } as any) const AuthorizedWizardEdgeWizardRoute = AuthorizedWizardEdgeWizardRouteImport.update({ id: '/_wizard/edge-wizard', @@ -304,6 +311,7 @@ export interface FileRoutesByFullPath { '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute + '/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -345,6 +353,7 @@ export interface FileRoutesByTo { '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute + '/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -390,6 +399,7 @@ export interface FileRoutesById { '/_authorized/_wizard/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/_authorized/_wizard/add-location': typeof AuthorizedWizardAddLocationRoute '/_authorized/_wizard/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute + '/_authorized/_wizard/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute '/_authorized/_wizard/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -434,6 +444,7 @@ export interface FileRouteTypes { | '/add-external-openid' | '/add-location' | '/edge-wizard' + | '/gateway-wizard' | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -475,6 +486,7 @@ export interface FileRouteTypes { | '/add-external-openid' | '/add-location' | '/edge-wizard' + | '/gateway-wizard' | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -519,6 +531,7 @@ export interface FileRouteTypes { | '/_authorized/_wizard/add-external-openid' | '/_authorized/_wizard/add-location' | '/_authorized/_wizard/edge-wizard' + | '/_authorized/_wizard/gateway-wizard' | '/_authorized/_wizard/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -672,6 +685,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedWizardSetupWizardRouteImport parentRoute: typeof AuthorizedRoute } + '/_authorized/_wizard/gateway-wizard': { + id: '/_authorized/_wizard/gateway-wizard' + path: '/gateway-wizard' + fullPath: '/gateway-wizard' + preLoaderRoute: typeof AuthorizedWizardGatewayWizardRouteImport + parentRoute: typeof AuthorizedRoute + } '/_authorized/_wizard/edge-wizard': { id: '/_authorized/_wizard/edge-wizard' path: '/edge-wizard' @@ -914,6 +934,7 @@ interface AuthorizedRouteChildren { AuthorizedWizardAddExternalOpenidRoute: typeof AuthorizedWizardAddExternalOpenidRoute AuthorizedWizardAddLocationRoute: typeof AuthorizedWizardAddLocationRoute AuthorizedWizardEdgeWizardRoute: typeof AuthorizedWizardEdgeWizardRoute + AuthorizedWizardGatewayWizardRoute: typeof AuthorizedWizardGatewayWizardRoute AuthorizedWizardSetupWizardRoute: typeof AuthorizedWizardSetupWizardRoute } @@ -923,6 +944,7 @@ const AuthorizedRouteChildren: AuthorizedRouteChildren = { AuthorizedWizardAddExternalOpenidRoute, AuthorizedWizardAddLocationRoute: AuthorizedWizardAddLocationRoute, AuthorizedWizardEdgeWizardRoute: AuthorizedWizardEdgeWizardRoute, + AuthorizedWizardGatewayWizardRoute: AuthorizedWizardGatewayWizardRoute, AuthorizedWizardSetupWizardRoute: AuthorizedWizardSetupWizardRoute, } diff --git a/web/src/routes/_authorized/_wizard/gateway-wizard.tsx b/web/src/routes/_authorized/_wizard/gateway-wizard.tsx new file mode 100644 index 0000000000..dbe77b7a96 --- /dev/null +++ b/web/src/routes/_authorized/_wizard/gateway-wizard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { GatewaySetupPage } from '../../../pages/GatewaySetupPage/GatewaySetupPage'; + +export const Route = createFileRoute('/_authorized/_wizard/gateway-wizard')({ + component: GatewaySetupPage, +}); diff --git a/web/src/shared/components/modals/GatewaySetupModal/GatewaySetupModal.tsx b/web/src/shared/components/modals/GatewaySetupModal/GatewaySetupModal.tsx deleted file mode 100644 index 226f55312d..0000000000 --- a/web/src/shared/components/modals/GatewaySetupModal/GatewaySetupModal.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Modal } from '../../../defguard-ui/components/Modal/Modal'; -import { isPresent } from '../../../defguard-ui/utils/isPresent'; -import { - subscribeCloseModal, - subscribeOpenModal, -} from '../../../hooks/modalControls/modalsSubjects'; -import { ModalName } from '../../../hooks/modalControls/modalTypes'; -import type { OpenGatewaySetupModal } from '../../../hooks/modalControls/types'; -import './style.scss'; -import { useEffect, useMemo, useState } from 'react'; -import { m } from '../../../../paraglide/messages'; -import api from '../../../api/api'; -import { Badge } from '../../../defguard-ui/components/Badge/Badge'; -import { Button } from '../../../defguard-ui/components/Button/Button'; -import { CopyField } from '../../../defguard-ui/components/CopyField/CopyField'; -import { Divider } from '../../../defguard-ui/components/Divider/Divider'; -import { MarkedSection } from '../../../defguard-ui/components/MarkedSection/MarkedSection'; -import { SizedBox } from '../../../defguard-ui/components/SizedBox/SizedBox'; -import { ThemeSpacing } from '../../../defguard-ui/types'; -import { DescriptionBlock } from '../../DescriptionBlock/DescriptionBlock'; - -const modalNameValue = ModalName.GatewaySetup; - -type ModalData = OpenGatewaySetupModal & { - initialGw: string[]; -}; - -export const GatewaySetupModal = () => { - const [isOpen, setOpen] = useState(false); - const [modalData, setModalData] = useState(null); - - useEffect(() => { - const openSub = subscribeOpenModal(modalNameValue, async (data) => { - const { data: gwStatus } = await api.location.getLocationGatewaysStatus( - data.networkId, - ); - - setModalData({ - ...data, - initialGw: gwStatus.map((gw) => gw.uid), - }); - setOpen(true); - }); - const closeSub = subscribeCloseModal(modalNameValue, () => setOpen(false)); - return () => { - openSub.unsubscribe(); - closeSub.unsubscribe(); - }; - }, []); - - return ( - setOpen(false)} - afterClose={() => { - setModalData(null); - }} - > - {isPresent(modalData) && } - - ); -}; - -const ModalContent = ({ data, networkId, initialGw }: ModalData) => { - const { - data: gwStatus, - refetch: refetchGwStatus, - isRefetching, - } = useQuery({ - queryFn: () => api.location.getLocationGatewaysStatus(networkId), - queryKey: ['network', networkId, 'gateways'], - select: (resp) => resp.data, - refetchInterval: 60_000, - }); - - const isNewConnected = useMemo(() => { - if (gwStatus) { - const newGw = gwStatus.find((gw) => !initialGw.includes(gw.uid)); - return isPresent(newGw); - } - return false; - }, [gwStatus, initialGw]); - - return ( - <> - - -

Use the token below to authenticate and configure your gateway node.

-
- - - - - -
- - -

- Once everything is set up and your token is entered, check the connection - status below. If it still fails, review the gateway logs. -

-
- -
- {isNewConnected && ( - - )} - {!isNewConnected && ( - - )} -
-
- - ); -}; diff --git a/web/src/shared/components/modals/GatewaySetupModal/style.scss b/web/src/shared/components/modals/GatewaySetupModal/style.scss deleted file mode 100644 index fa92bdc678..0000000000 --- a/web/src/shared/components/modals/GatewaySetupModal/style.scss +++ /dev/null @@ -1,9 +0,0 @@ -#gateway-setup-modal .modal-content { - .connection-status { - display: flex; - flex-flow: row nowrap; - align-items: center; - justify-content: space-between; - column-gap: var(--spacing-2xl); - } -} diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts index 5082d0db8e..7e5e0ddbe5 100644 --- a/web/src/shared/hooks/modalControls/modalTypes.ts +++ b/web/src/shared/hooks/modalControls/modalTypes.ts @@ -12,7 +12,6 @@ import type { OpenEditDeviceModal, OpenEditNetworkDeviceModal, OpenEditUserModal, - OpenGatewaySetupModal, OpenLicenseModal, OpenNetworkDeviceConfigModal, OpenNetworkDeviceTokenModal, @@ -136,10 +135,6 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [ name: z.literal(ModalName.DisplayList), data: z.custom(), }), - z.object({ - name: z.literal(ModalName.GatewaySetup), - data: z.custom(), - }), z.object({ name: z.literal(ModalName.AddLocation), }), diff --git a/web/src/shared/hooks/modalControls/types.ts b/web/src/shared/hooks/modalControls/types.ts index 6613abcab0..e01e845d85 100644 --- a/web/src/shared/hooks/modalControls/types.ts +++ b/web/src/shared/hooks/modalControls/types.ts @@ -1,7 +1,6 @@ import type { AvailableLocationIpResponse, Device, - GatewayTokenResponse, GroupInfo, NetworkDevice, NetworkLocation, @@ -87,11 +86,6 @@ export interface OpenDisplayListModal { data: string[]; } -export interface OpenGatewaySetupModal { - networkId: number; - data: GatewayTokenResponse; -} - export interface OpenLicenseModal { license?: string | null; } diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts index b9c4d6cb7b..b955d40f44 100644 --- a/web/src/shared/patterns.ts +++ b/web/src/shared/patterns.ts @@ -67,7 +67,7 @@ export const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; export const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; export const ipv4WithCIDRPattern = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; export const domainPattern = - /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))$/; + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; export const domainWithPortPattern = /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3})):[0-9]{1,5}$/;