diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 65087a3408..ef2682aa58 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -27,7 +27,9 @@ use crate::{ ModelError, WireguardNetwork, user::User, vpn_client_session::VpnClientSessionState, - wireguard::{LocationMfaMode, NetworkAddressError, ServiceLocationMode}, + wireguard::{ + LocationMfaMode, NetworkAddressError, ServiceLocationMode, WireguardNetworkError, + }, }, }, }; @@ -539,9 +541,13 @@ pub enum DeviceError { #[error("Database error")] DatabaseError(#[from] sqlx::Error), #[error(transparent)] + ModelError(#[from] ModelError), + #[error(transparent)] NetworkIpAssignmentError(#[from] NetworkAddressError), #[error("Unexpected error: {0}")] Unexpected(String), + #[error("Network {0} is full, no IP addresses available for device")] + NetworkFull(String), } impl Device { @@ -780,12 +786,23 @@ impl Device { continue; } - // FIXME: don't ignore the error. - let Ok(wireguard_network_device) = - network.add_device_to_network(&mut *conn, self, None).await - else { - warn!("Failed to add device {self} to network {network}"); - continue; + let wireguard_network_device = match network + .add_device_to_network(&mut *conn, self, None) + .await + { + Ok(device) => device, + Err(WireguardNetworkError::DeviceNotAllowed(_)) => { + debug!("Device {self} is not allowed in network {network}, skipping"); + continue; + } + Err(WireguardNetworkError::DeviceError(DeviceError::NetworkFull(_))) => { + warn!("Network {network} is full, no IP addresses available for device {self}"); + return Err(DeviceError::NetworkFull(network.name.clone())); + } + Err(err) => { + warn!("Failed to add device {self} to network {network}: {err}"); + return Err(DeviceError::Unexpected(err.to_string())); + } }; debug!( @@ -846,7 +863,7 @@ impl Device { /// # Returns /// /// - `Ok(WireguardNetworkDevice)`: A new relation linking this device to its assigned IPs across all subnets. - /// - `Err(ModelError::CannotCreate)`: If any subnet lacks an available IP. + /// - `Err(DeviceError::NetworkFull)`: If any subnet lacks an available IP. pub async fn assign_next_network_ip( &self, transaction: &mut PgConnection, @@ -854,7 +871,7 @@ impl Device { used_ips: &HashSet, reserved_ips: Option<&[IpAddr]>, current_ips: Option<&[IpAddr]>, - ) -> Result { + ) -> Result { debug!( "Assiging IP addresses for device: {} in network {}", self.name, network.name @@ -899,7 +916,7 @@ impl Device { "Failed to assign address for device {} in network {address:?}", self.name, ); - ModelError::CannotCreate + DeviceError::NetworkFull(address.to_string()) })?; // Otherwise, store the IP address diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 23c20d659c..95134cdd6a 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -377,7 +377,7 @@ impl WireguardNetwork { &self, transaction: &mut PgConnection, user_id: Id, - ) -> Result>, ModelError> { + ) -> Result>, DeviceError> { debug!("Fetching all allowed devices for network {self}, user ID {user_id}"); let devices = match self.get_allowed_groups(&mut *transaction).await? { @@ -426,7 +426,7 @@ impl WireguardNetwork { pub async fn add_all_allowed_devices( &self, transaction: &mut PgConnection, - ) -> Result<(), ModelError> { + ) -> Result<(), DeviceError> { info!( "Assigning IPs in network {} for all existing devices ", self diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 5e5edbdd0c..dc457ca4b1 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -89,6 +89,8 @@ pub enum WebError { #[error(transparent)] #[schema(value_type=Object)] StaticIpError(#[from] StaticIpError), + #[error("Network full: {0}")] + NetworkFull(String), } impl From for WebError { @@ -122,6 +124,8 @@ impl From for WebError { DeviceError::DatabaseError(_) => Self::DbError(error.to_string()), DeviceError::NetworkIpAssignmentError(_) => Self::ModelError(error.to_string()), DeviceError::Unexpected(_) => Self::Http(StatusCode::INTERNAL_SERVER_ERROR), + DeviceError::NetworkFull(_) => Self::NetworkFull(error.to_string()), + DeviceError::ModelError(_) => Self::ModelError(error.to_string()), } } } diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 857e486b0b..7811bac66c 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -56,6 +56,12 @@ pub mod wireguard; pub mod worker; pub(crate) mod yubikey; +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WebErrorCode { + NetworkFull, +} + pub static SESSION_COOKIE_NAME: &str = "defguard_session"; pub(crate) static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; pub(crate) const SIGN_IN_COOKIE_MAX_AGE: time::Duration = time::Duration::minutes(10); @@ -224,6 +230,13 @@ impl From for ApiResponse { error!(msg); ApiResponse::new(json!({"msg": msg}), StatusCode::BAD_REQUEST) } + WebError::NetworkFull(msg) => { + warn!(msg); + ApiResponse::new( + json!({"msg": msg, "code": WebErrorCode::NetworkFull}), + StatusCode::BAD_REQUEST, + ) + } WebError::TemplateError(err) => { error!("Template error: {err}"); ApiResponse::new( diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index 0c03d194b8..d32bd05829 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -349,19 +349,21 @@ pub(crate) async fn find_available_ips( } transaction.commit().await?; - if split_ips.len() == network.address.len() { - debug!( - "Found addresses {:?} for new device i network {} ({:?})", - split_ips, network.name, network.address - ); - Ok(ApiResponse::json(split_ips, StatusCode::OK)) - } else { + if split_ips.len() != network.address.len() { warn!( "Failed to find available IPs for new device in network {} ({:?})", network.name, network.address ); - Ok(ApiResponse::with_status(StatusCode::NOT_FOUND)) + return Err(WebError::NetworkFull(format!( + "Network {} is full, no IP addresses available", + network.name + ))); } + debug!( + "Found addresses {:?} for new device in network {} ({:?})", + split_ips, network.name, network.address + ); + Ok(ApiResponse::json(split_ips, StatusCode::OK)) } #[derive(Serialize, Deserialize, Debug)] diff --git a/web/messages/en/api-error.json b/web/messages/en/api-error.json new file mode 100644 index 0000000000..a049422d23 --- /dev/null +++ b/web/messages/en/api-error.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "api_error_network_full": "Network is full, no IP addresses available" +} diff --git a/web/messages/en/location.json b/web/messages/en/location.json index c80b5f1a3b..2923ef87c2 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -9,6 +9,7 @@ "location_delete_failed": "Failed to delete location", "network_device_delete_success": "Network device deleted", "network_device_delete_failed": "Failed to delete network device", + "network_device_add_error": "Failed to load network data for adding device", "location_edit_failed": "Failed to update location", "location_edit_addresses_rewrite_warning": "Changing the Gateway VPN IP address or netmask will automatically reassign any device addresses that fall outside the new network range to a randomly selected available IP address." } diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index 61308a6bc2..6fc3720e14 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -27,7 +27,8 @@ "./messages/{locale}/settings.json", "./messages/{locale}/gateway_wizard.json", "./messages/{locale}/initial_wizard.json", - "./messages/{locale}/migration_wizard.json" + "./messages/{locale}/migration_wizard.json", + "./messages/{locale}/api-error.json" ] } } diff --git a/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx b/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx index 6f8ef16d42..29df046ae1 100644 --- a/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx +++ b/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx @@ -5,11 +5,17 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; +import type { AxiosError } from 'axios'; import { orderBy } from 'lodash-es'; import { useMemo } from 'react'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; -import { LocationMfaMode, type NetworkDevice } from '../../shared/api/types'; +import { getApiErrorMessage } from '../../shared/api/apiErrorMessages'; +import { + type ApiError, + LocationMfaMode, + type NetworkDevice, +} from '../../shared/api/types'; import { Badge } from '../../shared/defguard-ui/components/Badge/Badge'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; @@ -24,6 +30,7 @@ import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/T import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; import { TableEditCell } from '../../shared/defguard-ui/components/table/TableEditCell/TableEditCell'; import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSize } from '../../shared/defguard-ui/types'; import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; @@ -63,6 +70,15 @@ export const NetworkDevicesTable = ({ networkDevices }: Props) => { reservedNames, }); }, + onError: (e) => { + console.error(e); + const code = (e as AxiosError).response?.data?.code; + if (code) { + Snackbar.error(getApiErrorMessage(code)); + } else { + Snackbar.error(m.network_device_add_error()); + } + }, }); const addButtonProps = useMemo( diff --git a/web/src/shared/api/apiErrorMessages.ts b/web/src/shared/api/apiErrorMessages.ts new file mode 100644 index 0000000000..0a4a2dde68 --- /dev/null +++ b/web/src/shared/api/apiErrorMessages.ts @@ -0,0 +1,7 @@ +import { m } from '../../paraglide/messages'; +import type { ApiErrorMessageKey, WebErrorCode } from './types'; + +export function getApiErrorMessage(code: WebErrorCode): string { + const key: ApiErrorMessageKey = `api_error_${code}`; + return (m as Record string>)[key](); +} diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index ef0fac4845..9cc252d782 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -305,9 +305,18 @@ export interface MfaFinishResponse { user?: User; } +export const WebErrorCode = { + NetworkFull: 'network_full', +} as const; + +export type WebErrorCode = (typeof WebErrorCode)[keyof typeof WebErrorCode]; + +export type ApiErrorMessageKey = `api_error_${WebErrorCode}`; + export interface ApiError { msg?: string; message?: string; + code?: WebErrorCode; } export interface AppInfoExceededLimits { diff --git a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx index 36203d2816..868db1e14d 100644 --- a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx +++ b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx @@ -10,8 +10,10 @@ import { useMutation } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; import { useMemo } from 'react'; import api from '../../../../../api/api'; +import { getApiErrorMessage } from '../../../../../api/apiErrorMessages'; import type { ApiError } from '../../../../../api/types'; import { SizedBox } from '../../../../../defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../../../defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../../defguard-ui/types'; import { patternValidWireguardKey } from '../../../../../patterns'; import { generateWGKeys } from '../../../../../utils/generateWGKeys'; @@ -107,6 +109,11 @@ export const AddDeviceModalManualSetupStep = () => { }, }, }); + } else { + const code = (e as AxiosError).response?.data?.code; + if (code) { + Snackbar.error(getApiErrorMessage(code)); + } } }); @@ -114,6 +121,7 @@ export const AddDeviceModalManualSetupStep = () => { if (!createResponse.data.configs.length) { useAddUserDeviceModal.getState().close(); + return; } useAddUserDeviceModal.setState({