diff --git a/crates/defguard_core/src/enterprise/handlers/device_posture.rs b/crates/defguard_core/src/enterprise/handlers/device_posture.rs index fde1efb32e..372527cab5 100644 --- a/crates/defguard_core/src/enterprise/handlers/device_posture.rs +++ b/crates/defguard_core/src/enterprise/handlers/device_posture.rs @@ -1163,12 +1163,6 @@ pub async fn set_postures_for_location( .await? .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; - if location.is_service_location() && !data.postures.is_empty() { - return Err(WebError::BadRequest( - "Posture checks cannot be assigned to service locations".to_owned(), - )); - } - let mut tx = appstate.pool.begin().await?; let old_postures = DevicePostureLocation::find_by_location(&mut *tx, location_id).await?; let result = @@ -1234,16 +1228,6 @@ pub async fn set_locations_for_posture( WebError::ObjectNotFound(format!("Device posture check {posture_id} not found")) })?; - for location_id in &data.locations { - if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, *location_id).await? - && location.is_service_location() - { - return Err(WebError::BadRequest( - "Posture checks cannot be assigned to service locations".to_owned(), - )); - } - } - let mut tx = appstate.pool.begin().await?; let old_locations = DevicePostureLocation::find_by_posture(&mut *tx, posture_id).await?; let result = diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 9554d0a381..5484906fab 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -212,7 +212,7 @@ impl ClientMfaServer { })?; // validate user is allowed to connect to a given location - Self::validate_location_access(&self.pool, &location, &user_info).await?; + Self::validate_location_access(&self.pool, &location, &device, &user_info).await?; // Evaluate postures if necessary. let has_postures = location.has_postures(&self.pool).await.map_err(|err| { @@ -470,10 +470,11 @@ impl ClientMfaServer { })) } - /// Checks if given user is allowed to access a location + /// Checks whether the user and device are allowed to access a location. async fn validate_location_access( pool: &PgPool, location: &WireguardNetwork, + device: &Device, user_info: &UserInfo, ) -> Result<(), Status> { // acquire connection @@ -502,10 +503,26 @@ impl ClientMfaServer { {allowed_groups:?}", user_info.username, user_info.groups ); - Err(Status::unauthenticated("unauthorized")) - } else { - Ok(()) + return Err(Status::unauthenticated("unauthorized")); + } + + let assignment = WireguardNetworkDevice::find(&mut *conn, device.id, location.id) + .await + .map_err(|err| { + error!( + "Failed to validate assignment for device {device} in location {location}: \ + {err}" + ); + Status::internal("unexpected error") + })?; + if assignment.is_none() { + error!("Device {device} is not assigned to location {location}"); + return Err(Status::permission_denied( + "device is not assigned to location", + )); } + + Ok(()) } #[instrument(skip_all)] @@ -871,9 +888,13 @@ impl ClientMfaServer { /// with a generated preshared key. Returns a typed outcome so the caller can /// map it to the appropriate `CoreResponse` payload without needing to know about /// session internals. + /// + /// A location with no postures assigned is approved with an *empty* preshared key and no + /// session, since its peers are handed to the gateway without one. pub async fn handle_posture_check( &mut self, request: DevicePostureCheckRequest, + info: Option, ) -> Result { debug!( "Handling posture check for device pubkey={} location_id={}", @@ -937,17 +958,6 @@ impl ClientMfaServer { return Err(Status::unauthenticated("token does not match device")); } - if !location.has_postures(&self.pool).await.map_err(|err| { - error!("Posture check: failed to fetch postures for location {location}: {err}"); - Status::internal("unexpected error") - })? { - error!( - "Posture check: location {location} has no postures defined but device {} requested posture check", - device.wireguard_pubkey - ); - return Err(Status::invalid_argument("location does not use postures")); - } - let Ok(Some(user)) = User::find_by_id(&self.pool, device.user_id).await else { error!("Posture check: user {} not found", device.user_id); return Err(Status::internal("user not found")); @@ -971,10 +981,26 @@ impl ClientMfaServer { ); Status::internal("unexpected error") })?; - Self::validate_location_access(&self.pool, &location, &user_info).await?; + Self::validate_location_access(&self.pool, &location, &device, &user_info).await?; + + // If location has no postures assigned, approve the posture check returning empty string as PSK. + // This way the client can recover on it's own if the admin unassigns PCs from a location and the client + // didn't get the config yet. Matters especially for service locations where the client UI may not be + // running and therefore config is not being polled. + if !location.has_postures(&self.pool).await.map_err(|err| { + error!("Posture check: failed to fetch postures for location {location}: {err}"); + Status::internal("unexpected error") + })? { + info!( + "Posture check: location {location} has no postures assigned, approving device {} \ + with an empty preshared key without creating a session", + device.wireguard_pubkey + ); + return Ok(PostureCheckOutcome::Approved { + preshared_key: String::new(), + }); + } - // Evaluate posture. `location.id` rather than `request.location_id`: the location was - // already looked up and validated above, so this passes the trusted value. let posture_result = match validate_posture( &self.pool, location.id, @@ -994,12 +1020,42 @@ impl ClientMfaServer { } }; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; + let context = + BidiRequestContext::new(user.id, user.username.clone(), ip, device.name.clone()); + // Posture check failed - return payload with reasons if let PostureResult::Fail(reasons) = posture_result { - let failed_checks = reasons.iter().map(ToString::to_string).collect(); + let failed_checks = reasons.iter().map(ToString::to_string).collect::>(); + if let Err(err) = self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::PostureCheckFailed { + device: device.clone(), + location: location.clone(), + device_posture_data: request.device_posture_data.clone(), + failed_checks: failed_checks.clone(), + }, + )), + }) { + error!("Failed to emit DevicePostureCheckFailed event: {err}"); + } return Ok(PostureCheckOutcome::Rejected { failed_checks }); } + if let Err(err) = self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::PostureCheckPassed { + device: device.clone(), + location: location.clone(), + device_posture_data: request.device_posture_data.clone(), + }, + )), + }) { + error!("Failed to emit DevicePostureCheckPassed event: {err}"); + } + // Posture check succeeded - create a vpn session let key = WireguardNetwork::genkey(); @@ -1217,8 +1273,11 @@ mod tests { }, setup_pool, }; - use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, bool_check, + use defguard_proto::{ + enterprise::posture::{ + BoolCheck, DevicePostureCheckRequest, DevicePostureData, bool_check, + }, + proxy::DeviceInfo, }; use ipnetwork::IpNetwork; use sqlx::{ @@ -1243,6 +1302,16 @@ mod tests { const REPLACEMENT_MFA_PRESHARED_KEY: &str = "replacement-mfa-psk"; const NEW_MFA_PRESHARED_KEY: &str = "new-psk"; + const DEVICE_INFO_IP: &str = "10.0.0.7"; + + /// The `DeviceInfo` the proxy attaches to every bidi request; audit events are built from it. + fn device_info() -> Option { + Some(DeviceInfo { + ip_address: DEVICE_INFO_IP.to_owned(), + user_agent: Some("defguard-client/1.6.0".to_owned()), + ..Default::default() + }) + } #[sqlx::test] async fn test_posture_check_success_emits_vpn_session_authorized_event( @@ -1263,12 +1332,15 @@ mod tests { let (mut server, _event_rx, mut gateway_rx) = make_server(pool.clone()); let outcome = server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - token: Some(token.clone()), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), + }, + device_info(), + ) .await .expect("posture check should pass"); let preshared_key = match outcome { @@ -1341,12 +1413,15 @@ mod tests { let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - token: Some(token.clone()), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), + }, + device_info(), + ) .await .expect("replacement posture check should pass"); @@ -1372,6 +1447,18 @@ mod tests { other => panic!("unexpected gateway event: {other:?}"), } + // the passing posture evaluation is audited first + let event = event_rx + .try_recv() + .expect("expected posture check passed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckPassed { .. } => {} + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + // replacing a connected posture-only session emits the unified session // superseded audit event, flagged as a non-MFA session let event = event_rx @@ -1418,12 +1505,15 @@ mod tests { for token in [None, Some(String::new())] { let err = server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - token, - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token, + }, + device_info(), + ) .await; let err = match err { Ok(_) => panic!("posture check without a token must be refused"), @@ -1462,12 +1552,15 @@ mod tests { let (mut server, _, _) = make_server(pool); let err = server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - token: Some("not-a-real-token".to_owned()), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some("not-a-real-token".to_owned()), + }, + device_info(), + ) .await; let err = match err { Ok(_) => panic!("posture check with an unknown token must be refused"), @@ -1529,12 +1622,15 @@ mod tests { // Attacker presents its own valid token but claims the victim's public key. let err = server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: victim.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - token: Some(attacker_token), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: victim.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(attacker_token), + }, + device_info(), + ) .await; let err = match err { Ok(_) => panic!("a token from another device must not authorize this one"), @@ -1573,12 +1669,15 @@ mod tests { let (mut server, _, _) = make_server(pool); let err = match server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: "irrelevant".to_owned(), - device_posture_data: None, - token: Some(token), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: "irrelevant".to_owned(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) .await { Ok(_) => panic!("MFA-enabled location should reject posture-only flow"), @@ -1588,33 +1687,299 @@ mod tests { assert_eq!(err.code(), Code::InvalidArgument); } + /// A location with no postures assigned hands its peers to the gateway without a preshared + /// key, so the only answer that lets a client connect is an empty one. Approving instead of + /// erroring is what allows a service location whose cached config still demands a posture check + /// to recover after an admin unassigns the last posture. #[sqlx::test] - async fn test_posture_check_rejects_location_without_postures( + async fn test_posture_check_without_postures_approves_with_empty_preshared_key( _: PgPoolOptions, options: PgConnectOptions, ) { + set_enterprise_license(); let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); let location = create_non_mfa_location(&pool).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; let token = create_polling_token(&pool, device.id).await; - let (mut server, _, _) = make_server(pool); + let (mut server, _event_rx, mut gateway_rx) = make_server(pool.clone()); - let err = match server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey, - device_posture_data: None, - token: Some(token), - }) + let outcome = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) + .await + .expect("location without postures should be approved"); + + match outcome { + super::PostureCheckOutcome::Approved { preshared_key } => assert!( + preshared_key.is_empty(), + "a location without postures must not hand out a preshared key" + ), + super::PostureCheckOutcome::Rejected { failed_checks } => { + panic!("posture check unexpectedly failed: {failed_checks:?}") + } + } + + // No session may be created and the gateway must not be touched. + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty(), + "no VPN session may be created when a location has no postures" + ); + assert!( + gateway_rx.try_recv().is_err(), + "no gateway command may be sent when a location has no postures" + ); + } + + #[sqlx::test] + async fn test_posture_check_without_postures_rejects_device_not_assigned_to_location( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, mut gateway_rx) = make_server(pool); + + let status = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey, + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) .await { - Ok(_) => panic!("location without postures should reject posture-only flow"), - Err(err) => err, + Ok(_) => panic!("a device not assigned to the location must not be approved"), + Err(status) => status, }; - assert_eq!(err.code(), Code::InvalidArgument); + assert_eq!(status.code(), Code::PermissionDenied); + assert_eq!(status.message(), "device is not assigned to location"); + assert!(event_rx.try_recv().is_err()); + assert!(gateway_rx.try_recv().is_err()); + } + + /// The empty-preshared-key approval must not outrank the access checks: deactivating a user has + /// to stop their devices from getting anything that reads as approval, even on a location with + /// no postures where the approval grants nothing by itself. + #[sqlx::test] + async fn test_posture_check_without_postures_still_rejects_inactive_user( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + let mut user = create_user(&pool).await; + user.is_active = false; + user.save(&pool).await.expect("failed to deactivate user"); + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, _event_rx, _gateway_rx) = make_server(pool.clone()); + + let status = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) + .await + { + Ok(super::PostureCheckOutcome::Approved { .. }) => { + panic!("an inactive user must not be approved, even without postures") + } + Ok(super::PostureCheckOutcome::Rejected { .. }) => { + panic!("expected an inactive-user error, not a posture rejection") + } + Err(status) => status, + }; + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(status.message(), "user is inactive"); + } + + /// A passing posture evaluation must be auditable, so an operator can see that a headless + /// service location connected and why. + #[sqlx::test] + async fn test_posture_check_pass_emits_posture_check_passed_event( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, _gateway_rx) = make_server(pool.clone()); + + let posture_data = passing_linux_posture_data(); + match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(posture_data.clone()), + token: Some(token), + }, + device_info(), + ) + .await + .expect("posture check should pass") + { + super::PostureCheckOutcome::Approved { preshared_key } => { + assert!(!preshared_key.is_empty()); + } + super::PostureCheckOutcome::Rejected { failed_checks } => { + panic!("posture check unexpectedly failed: {failed_checks:?}") + } + } + + let event = event_rx + .try_recv() + .expect("expected posture check passed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckPassed { + device: event_device, + location: event_location, + device_posture_data, + } => { + assert_eq!(event_device.id, device.id); + assert_eq!(event_location.id, location.id); + assert_eq!(device_posture_data, Some(posture_data)); + } + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + assert_eq!(event.context.user_id, user.id); + assert_eq!(event.context.username, user.username); + assert_eq!(event.context.ip, Some(DEVICE_INFO_IP.parse().unwrap())); + } + + /// A failing posture evaluation must be auditable too - on a service location this is the only + /// way to find out why the tunnel never came up. + #[sqlx::test] + async fn test_posture_check_failure_emits_posture_check_failed_event( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); + + // the policy requires disk encryption + let posture_data = DevicePostureData { + disk_encryption: Some(BoolCheck { + result: Some(bool_check::Result::Value(false)), + }), + ..passing_linux_posture_data() + }; + let rejected_checks = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(posture_data.clone()), + token: Some(token), + }, + device_info(), + ) + .await + .expect("posture check should complete") + { + super::PostureCheckOutcome::Approved { .. } => { + panic!("posture check with unencrypted disk should be rejected") + } + super::PostureCheckOutcome::Rejected { failed_checks } => failed_checks, + }; + assert!(!rejected_checks.is_empty()); + + let event = event_rx + .try_recv() + .expect("expected posture check failed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckFailed { + device: event_device, + location: event_location, + device_posture_data, + failed_checks, + } => { + assert_eq!(event_device.id, device.id); + assert_eq!(event_location.id, location.id); + assert_eq!(device_posture_data, Some(posture_data)); + assert_eq!(failed_checks, rejected_checks); + } + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + assert_eq!(event.context.user_id, user.id); + assert_eq!(event.context.username, user.username); + + // a rejected check must not authorize anything + assert!(gateway_rx.try_recv().is_err()); + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty() + ); } #[sqlx::test] diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 9c0b33bca8..ca7ca9f760 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -139,6 +139,32 @@ impl WireguardNetworkData { Ok(()) } + /// Rejects service-location mode combined with location MFA: core cannot serve it and the + /// client cannot represent it (`Location::is_service_location()` requires MFA disabled). + pub(crate) fn validate_service_location_mfa(&self) -> Result<(), WebError> { + if self.service_location_mode == ServiceLocationMode::Disabled + || self.location_mfa_mode == LocationMfaMode::Disabled + { + return Ok(()); + } + + Err(WebError::BadRequest( + "Service location mode cannot be combined with location MFA".into(), + )) + } + + /// Rejects a zero (or negative) keepalive interval to prevent idle service locations + /// from disconnecting. + pub(crate) fn validate_keepalive_interval(&self) -> Result<(), WebError> { + if self.keepalive_interval >= 1 { + return Ok(()); + } + + Err(WebError::BadRequest( + "keepalive_interval must be at least 1".into(), + )) + } + pub(crate) fn validate_allowed_groups(&self) -> Result<(), WebError> { if self.allow_all_groups || !self.allowed_groups.is_empty() { return Ok(()); @@ -233,6 +259,8 @@ pub(crate) async fn create_network( data.validate_peer_disconnect_threshold()?; data.validate_location_mfa_mode(&appstate.pool).await?; + data.validate_service_location_mfa()?; + data.validate_keepalive_interval()?; data.validate_allowed_groups()?; let allowed_ips = data.parse_allowed_ips(); @@ -362,6 +390,8 @@ pub(crate) async fn modify_network( data.validate_peer_disconnect_threshold()?; data.validate_location_mfa_mode(&appstate.pool).await?; + data.validate_service_location_mfa()?; + data.validate_keepalive_interval()?; data.validate_allowed_groups()?; let network = find_network(network_id, &appstate.pool).await?; @@ -385,35 +415,25 @@ pub(crate) async fn modify_network( network.acl_enabled = data.acl_enabled; network.acl_default_allow = data.acl_default_allow; network.allowed_ips_from_acl = data.allowed_ips_from_acl; - network.service_location_mode = if data.location_mfa_mode == LocationMfaMode::Disabled { - data.service_location_mode - } else { - warn!( - "Disabling service location mode for location {} because location MFA is enabled", - network.name - ); - ServiceLocationMode::Disabled - }; + network.service_location_mode = data.service_location_mode; network.location_mfa_mode = data.location_mfa_mode; network.save(&mut *transaction).await?; network .set_allowed_groups(&mut transaction, &data.allowed_groups) .await?; + let _events = sync_location_allowed_devices(&network, &mut transaction, None).await?; let peers = get_location_allowed_peers(&network, &mut transaction).await?; let maybe_firewall_config = try_get_location_firewall_config(&network, &mut transaction).await?; - appstate.send_gateway_command(GatewayCommand::NetworkModified( - network.id, - network.clone(), - peers, - maybe_firewall_config, - )); + let gateway_command = + GatewayCommand::NetworkModified(network.id, network.clone(), peers, maybe_firewall_config); // commit DB transaction transaction.commit().await?; + appstate.send_gateway_command(gateway_command); info!( "User {} updated WireGuard network {network_id}", diff --git a/crates/defguard_core/tests/integration/api/device_posture.rs b/crates/defguard_core/tests/integration/api/device_posture.rs index 3f802fc55b..7fea42fafa 100644 --- a/crates/defguard_core/tests/integration/api/device_posture.rs +++ b/crates/defguard_core/tests/integration/api/device_posture.rs @@ -1303,7 +1303,7 @@ async fn make_service_location(client: &TestClient, name: &str) -> i64 { } #[sqlx::test] -async fn test_set_postures_for_service_location_rejected( +async fn test_set_postures_for_service_location_allowed( _: PgPoolOptions, options: PgConnectOptions, ) { @@ -1319,7 +1319,7 @@ async fn test_set_postures_for_service_location_rejected( .await; client.drain_all_events(); - // assigning posture checks to a service location is rejected + // assigning posture checks to a service location is allowed let response = client .put(format!("/api/v1/network/{service_location_id}/postures")) .json(&AssignPosturesData { @@ -1327,16 +1327,24 @@ async fn test_set_postures_for_service_location_rejected( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result, vec![posture.id]); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].0, + ApiEventType::LocationPosturesAssigned { .. } + )); - // nothing was assigned to the posture + // the assignment is visible on the posture let response = client .get(format!("/api/v1/device-posture/{}", posture.id)) .send() .await; let fetched: ApiDevicePosture = response.json().await; - assert!(fetched.locations.is_empty()); + assert_eq!(fetched.locations, vec![service_location_id]); // clearing (empty list) is still allowed on a service location let response = client @@ -1347,10 +1355,18 @@ async fn test_set_postures_for_service_location_rejected( .send() .await; assert_eq!(response.status(), StatusCode::OK); + client.drain_all_events(); + + let response = client + .get(format!("/api/v1/device-posture/{}", posture.id)) + .send() + .await; + let fetched: ApiDevicePosture = response.json().await; + assert!(fetched.locations.is_empty()); } #[sqlx::test] -async fn test_set_locations_for_posture_rejects_service_location( +async fn test_set_locations_for_posture_allows_service_location( _: PgPoolOptions, options: PgConnectOptions, ) { @@ -1369,7 +1385,7 @@ async fn test_set_locations_for_posture_rejects_service_location( .await; client.drain_all_events(); - // assigning a service location to a posture is rejected + // assigning a service location to a posture is allowed let response = client .put(format!("/api/v1/device-posture/{}/locations", posture.id)) .json(&AssignLocationsData { @@ -1377,10 +1393,18 @@ async fn test_set_locations_for_posture_rejects_service_location( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result, vec![service_location_id]); - // a mix containing a service location is rejected too — nothing is assigned + let events = client.drain_all_events(); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].0, + ApiEventType::DevicePostureLocationsAssigned { .. } + )); + + // a mix containing a service location is accepted too let response = client .put(format!("/api/v1/device-posture/{}/locations", posture.id)) .json(&AssignLocationsData { @@ -1388,15 +1412,21 @@ async fn test_set_locations_for_posture_rejects_service_location( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result.len(), 2); + assert!(result.contains(®ular_location_id)); + assert!(result.contains(&service_location_id)); + client.drain_all_events(); let response = client .get(format!("/api/v1/device-posture/{}", posture.id)) .send() .await; let fetched: ApiDevicePosture = response.json().await; - assert!(fetched.locations.is_empty()); + assert_eq!(fetched.locations.len(), 2); + assert!(fetched.locations.contains(®ular_location_id)); + assert!(fetched.locations.contains(&service_location_id)); // assigning only regular locations still works let response = client diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index e242d6487a..16cb7826e5 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -34,8 +34,8 @@ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::common::{ - authenticate_admin, exceed_enterprise_limits, fetch_user_details, make_network, - make_test_client, setup_pool, + authenticate_admin, client::TestClient, exceed_enterprise_limits, fetch_user_details, + make_network, make_test_client, setup_pool, }; const INVALID_MFA_PEER_DISCONNECT_THRESHOLD: i32 = 119; @@ -348,6 +348,466 @@ async fn test_create_network_with_posture_checks_requires_enterprise_license( })); } +/// Build a location payload with overridable name, address and mode fields. +/// `posture_checks` is intentionally absent — add it explicitly where it matters. +fn location_payload( + name: &str, + address: &str, + location_mfa_mode: &str, + service_location_mode: &str, +) -> serde_json::Value { + json!({ + "name": name, + "address": address, + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "location_mfa_mode": location_mfa_mode, + "service_location_mode": service_location_mode + }) +} + +/// Create a posture check and return its ID. +async fn make_posture_check(client: &TestClient, name: &str) -> i64 { + let response = client + .post("/api/v1/device-posture") + .json(&json!({ + "name": name, + "description": null, + "min_desktop_client_version": null, + "min_mobile_client_version": null, + "allow_prerelease_client": false, + "os_rules": [] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let posture: serde_json::Value = response.json().await; + posture["id"].as_i64().unwrap() +} + +/// Fetch the posture checks assigned to a location. +async fn fetch_location_postures(client: &TestClient, location_id: i64) -> Vec { + let response = client + .get(format!("/api/v1/network/{location_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let network: serde_json::Value = response.json().await; + serde_json::from_value(network["posture_checks"].clone()).unwrap() +} + +#[sqlx::test] +async fn test_modify_network_does_not_notify_gateway_when_commit_fails( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let response = client + .post("/api/v1/network") + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + let pool = client_state.pool.clone(); + let mut gateway_rx = client_state.gateway_rx; + assert_matches!( + gateway_rx.try_recv().unwrap(), + GatewayCommand::NetworkCreated(..) + ); + + sqlx::query( + "CREATE FUNCTION fail_network_update_commit() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'forced commit failure'; + END; + $$ LANGUAGE plpgsql", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "CREATE CONSTRAINT TRIGGER fail_network_update_commit + AFTER UPDATE ON wireguard_network + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fail_network_update_commit()", + ) + .execute(&pool) + .await + .unwrap(); + + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "renamed-location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_matches!( + gateway_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + ); + + let response = client + .get(format!("/api/v1/network/{}", location.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let persisted: WireguardNetwork = response.json().await; + assert_eq!(persisted.name, "location"); +} + +#[sqlx::test] +async fn test_create_network_rejects_service_location_with_mfa( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + for service_location_mode in ["prelogon", "alwayson"] { + let response = client + .post("/api/v1/network") + .json(&location_payload( + "mfa-service-location", + "10.1.1.1/24", + "internal", + service_location_mode, + )) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "MFA + service location mode {service_location_mode} must be rejected" + ); + } + + // MFA without service location mode is fine + let response = client + .post("/api/v1/network") + .json(&location_payload( + "mfa-only", + "10.1.1.1/24", + "internal", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // service location mode without MFA is fine + let response = client + .post("/api/v1/network") + .json(&location_payload( + "service-location-only", + "10.2.2.1/24", + "disabled", + "prelogon", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +/// A zero keepalive stops `last_handshake` from ever advancing on an idle tunnel, which would make +/// the posture health check re-authorize forever (D6/R7). The web forms block it, but an API caller +/// bypasses them entirely, so core has to reject it too. +#[sqlx::test] +async fn test_network_rejects_zero_keepalive_interval(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let mut payload = location_payload("zero-keepalive", "10.1.1.1/24", "disabled", "disabled"); + payload["keepalive_interval"] = json!(0); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "keepalive_interval 0 must be rejected on create" + ); + + // A valid location, so the same rule can be checked on the modify path. + let response = client + .post("/api/v1/network") + .json(&location_payload( + "good-keepalive", + "10.2.2.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let created: serde_json::Value = response.json().await; + let location_id = created["id"].as_i64().unwrap(); + + let mut payload = location_payload("good-keepalive", "10.2.2.1/24", "disabled", "disabled"); + payload["keepalive_interval"] = json!(0); + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&payload) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "keepalive_interval 0 must be rejected on modify" + ); + + // 1 is the floor, not a rejected edge. + payload["keepalive_interval"] = json!(1); + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&payload) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_modify_network_rejects_service_location_with_mfa( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let response = client + .post("/api/v1/network") + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + for service_location_mode in ["prelogon", "alwayson"] { + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "location", + "10.1.1.1/24", + "internal", + service_location_mode, + )) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "MFA + service location mode {service_location_mode} must be rejected" + ); + } + + // the rejected combination was not persisted in any form + let response = client + .get(format!("/api/v1/network/{}", location.id)) + .send() + .await; + let fetched: WireguardNetwork = response.json().await; + assert_eq!(fetched.location_mfa_mode, LocationMfaMode::Disabled); + assert_eq!(fetched.service_location_mode, ServiceLocationMode::Disabled); + + // enabling service location mode alone is accepted and persisted + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "prelogon", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!( + modified.service_location_mode, + ServiceLocationMode::PreLogon + ); +} + +#[sqlx::test] +async fn test_modify_network_without_posture_checks_keeps_assignments( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let posture = make_posture_check(&client, "Posture").await; + + let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // a payload with the field omitted must leave the assignment alone + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "renamed-location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!(modified.name, "renamed-location"); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // an explicit `null` behaves the same way + let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + payload["posture_checks"] = json!(null); + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&payload) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); +} + +#[sqlx::test] +async fn test_posture_checks_allowed_on_service_locations( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let posture = make_posture_check(&client, "Posture").await; + + // create path: a service location may carry posture checks + let mut payload = location_payload("service-location", "10.1.1.1/24", "disabled", "prelogon"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let service_location: WireguardNetwork = response.json().await; + assert_eq!( + service_location.service_location_mode, + ServiceLocationMode::PreLogon + ); + assert_eq!( + fetch_location_postures(&client, service_location.id).await, + vec![posture] + ); + + // modify path: turning a posture-carrying regular location into a service + // location keeps its posture checks + let mut payload = location_payload("regular-location", "10.2.2.1/24", "disabled", "disabled"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "regular-location", + "10.2.2.1/24", + "disabled", + "alwayson", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!( + modified.service_location_mode, + ServiceLocationMode::AlwaysOn + ); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // dedicated assignment path: posture checks can be assigned to an existing service location + let response = client + .post("/api/v1/network") + .json(&location_payload( + "service-location-without-postures", + "10.3.3.1/24", + "disabled", + "alwayson", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let service_location_without_postures: WireguardNetwork = response.json().await; + assert!( + fetch_location_postures(&client, service_location_without_postures.id) + .await + .is_empty() + ); + + let response = client + .put(format!( + "/api/v1/network/{}/postures", + service_location_without_postures.id + )) + .json(&json!({ "postures": [posture] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + fetch_location_postures(&client, service_location_without_postures.id).await, + vec![posture] + ); +} + #[sqlx::test] async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index be795dd391..656626c4c7 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -1048,7 +1048,12 @@ impl ProxyHandler { None } Some(core_request::Payload::DevicePostureCheck(request)) => { - match self.services.client_mfa.handle_posture_check(request).await { + match self + .services + .client_mfa + .handle_posture_check(request, received.device_info) + .await + { Ok(PostureCheckOutcome::Approved { preshared_key }) => { Some(core_response::Payload::DevicePostureCheck( DevicePostureCheckResponse { preshared_key }, diff --git a/proto b/proto index 569334098f..7e1c6a5ed1 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 569334098f1cd7e81809c3ccd7681acfc18a7491 +Subproject commit 7e1c6a5ed1336522bff0610edf1e216f7dcde444 diff --git a/web/messages/en/form.json b/web/messages/en/form.json index d5ffc350f2..eee1b19f8c 100644 --- a/web/messages/en/form.json +++ b/web/messages/en/form.json @@ -6,6 +6,7 @@ "form_error_file_contents": "File content is not valid", "form_error_ip_or_domain": "Only valid IP or domain is allowed", "form_error_port_max": "Port exceeds maximum value", + "form_error_keepalive_min": "Keep alive interval must be at least 1 second", "form_error_len": "Required length {length}", "form_error_name_reserved": "Name already taken", "form_error_email": "Enter valid email", diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 5f988a0526..08a0de2288 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -98,10 +98,8 @@ "location_mfa_option_internal": "Internal MFA", "location_mfa_option_external": "External MFA", "location_mfa_service_location_warning": "MFA can't be enabled for service locations. To enforce MFA, select the Regular location type.", - "location_posture_service_location_warning": "Postures can't be enabled for service locations. To enforce posture checks, select the Regular location type.", "location_edit_section_location_type": "Location type", "location_service_mode_mfa_warning": "MFA-protected locations can't be set as service locations. Disable MFA to use a service location type.", - "location_service_mode_postures_warning": "Locations with assigned posture checks can't be set as service locations. Remove posture checks to use a service location type.", "location_access_section_label": "Location Access", "location_access_selected_group_count_one": "+{count} group", "location_access_selected_group_count_other": "+{count} groups", diff --git a/web/src/pages/AddLocationPage/AddLocationPage.tsx b/web/src/pages/AddLocationPage/AddLocationPage.tsx index d253f4b1ee..5ef5dd36d0 100644 --- a/web/src/pages/AddLocationPage/AddLocationPage.tsx +++ b/web/src/pages/AddLocationPage/AddLocationPage.tsx @@ -91,7 +91,6 @@ export const AddLocationPage = () => { id: AddLocationPageStep.PostureCheck, order: 6, label: m.add_location_step_posture_check_label(), - hidden: locationType === 'service', description: m.add_location_step_posture_check_description(), }, firewall: { diff --git a/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx index be5807ebb9..28c138bc6a 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx @@ -95,10 +95,7 @@ export const AddLocationAccessStep = () => { } saveChanges(selected, allowAllGroups); useAddLocationStore.setState({ - activeStep: - locationType === 'service' - ? AddLocationPageStep.Firewall - : AddLocationPageStep.PostureCheck, + activeStep: AddLocationPageStep.PostureCheck, }); }} /> diff --git a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx index a164e4bba7..41fc65c022 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx @@ -26,7 +26,6 @@ import { useAddLocationStore } from '../useAddLocationStore'; type Choice = 'disable' | 'enabled-allowed' | 'enabled-denied'; export const AddLocationFirewallStep = () => { - const locationType = useAddLocationStore((s) => s.locationType); const [state, setState] = useState('disable'); const [showGateway, setShowGateway] = useState(true); const navigate = useNavigate(); @@ -150,10 +149,7 @@ export const AddLocationFirewallStep = () => { onClick={() => { saveChanges(state); useAddLocationStore.setState({ - activeStep: - locationType === 'service' - ? AddLocationPageStep.AccessControl - : AddLocationPageStep.PostureCheck, + activeStep: AddLocationPageStep.PostureCheck, }); }} /> diff --git a/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx index a551041a45..a7824d1675 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx @@ -14,6 +14,8 @@ import { useAddLocationStore } from '../useAddLocationStore'; const formSchema = z.object({ keepalive_interval: z .number(m.form_error_required()) + // Keepalive is mandatory to prevent idle service locations from disconnecting + .min(1, m.form_error_keepalive_min()) .max(65535, m.form_error_port_max()), mtu: z.number(m.form_error_required()).min(72).max(0xffffffff), fwmark: z.number(m.form_error_required()).min(0).max(0xffffffff), diff --git a/web/src/pages/EditLocationPage/EditLocationPage.tsx b/web/src/pages/EditLocationPage/EditLocationPage.tsx index e8b85640f5..df08df1606 100644 --- a/web/src/pages/EditLocationPage/EditLocationPage.tsx +++ b/web/src/pages/EditLocationPage/EditLocationPage.tsx @@ -154,6 +154,8 @@ const formSchema = z peer_disconnect_threshold: z.number().nullable(), keepalive_interval: z .number(m.form_error_required()) + // Keepalive is mandatory to prevent idle service locations from disconnecting + .min(1, m.form_error_keepalive_min()) .max(65535, m.form_error_port_max()), mtu: z.number(m.form_error_required()).min(72).max(0xffffffff), fwmark: z.number(m.form_error_required()).min(0).max(0xffffffff), @@ -812,13 +814,6 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { text={m.location_service_mode_mfa_warning()} /> )} - {postureChecksSectionState.hasAssignedPostureChecks && ( - - )} { @@ -945,98 +928,71 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { )} - - s.values.service_location_mode !== LocationServiceMode.Disabled - } + - {(isServiceLocation) => ( - <> - {isServiceLocation && ( - - )} - - {postureChecksSectionState.showEmptyState && ( -
- -

- {m.location_posture_checks_empty_state_before_link()}{' '} - - {m.cmp_nav_item_posture_checks()} - {' '} - {m.location_posture_checks_empty_state_after_link()} -

-
- )} - {postureChecksSectionState.showAssignedPostureChecks && ( -
- postureCheck.id), - ) - } - modalTitle={m.location_posture_checks_select()} - editText={m.location_posture_checks_edit()} - editIcon={IconKind.Edit} - toggleValue={false} - counterText={() => ''} - disabled={isServiceLocation} - onSelectionChange={(values) => { - setLocationPostures({ - postures: values.filter( - (value): value is number => typeof value === 'number', - ), - }); - }} - onToggleChange={() => {}} - selectionCustomItemRender={renderPostureCheckSelectionItem} - selectionModalProps={{ - contentClassName: 'posture-check-assignment-modal', - enableDividers: true, - itemGap: 12, - searchPlaceholder: m.controls_search(), - visibleItemsLimit: 6, - }} - /> -
- )} - {postureChecksSectionState.showAssignButton && ( -