Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions crates/defguard_common/src/db/models/wireguard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,37 @@ impl<I> WireguardNetwork<I> {
self.set_address(parsed_addresses)
}
}

/// Check if a given number of devices can fit in the networks used by this location.
///
/// The overhead of reserved addresses is computed per-subnet based on address family:
/// - IPv4: +3 (network address + broadcast address + gateway)
/// - IPv6: +2 (network address + gateway; IPv6 has no broadcast address)
pub fn validate_network_size(&self, device_count: usize) -> Result<(), WireguardNetworkError> {
debug!("Checking if {device_count} devices can fit in networks used by this location");
// If a given location uses multiple subnets, validate devices can fit them all.
for subnet in &self.address {
debug!("Checking if {device_count} devices can fit in network {subnet}");
match subnet.size() {
NetworkSize::V4(size) => {
// +3: network address, broadcast address, gateway
let count = (device_count + 3) as u32;
if count > size {
return Err(WireguardNetworkError::NetworkTooSmall);
}
}
NetworkSize::V6(size) => {
// +2: network address, gateway (IPv6 has no broadcast)
let count = (device_count + 2) as u128;
if count > size {
return Err(WireguardNetworkError::NetworkTooSmall);
}
}
}
}

Ok(())
}
}

impl WireguardNetwork<Id> {
Expand Down Expand Up @@ -395,30 +426,6 @@ impl WireguardNetwork<Id> {
.await
}

/// Check if given number of devices can fit in networks used by this location.
/// Note: `device_count` should include network and broadcast addresses.
pub fn validate_network_size(&self, device_count: usize) -> Result<(), WireguardNetworkError> {
debug!("Checking if {device_count} devices can fit in networks used by location {self}");
// If a given location uses multiple subnets, validate devices can fit them all.
for subnet in &self.address {
debug!("Checking if {device_count} devices can fit in network {subnet}");
match subnet.size() {
NetworkSize::V4(size) => {
if device_count as u32 > size {
return Err(WireguardNetworkError::NetworkTooSmall);
}
}
NetworkSize::V6(size) => {
if device_count as u128 > size {
return Err(WireguardNetworkError::NetworkTooSmall);
}
}
}
}

Ok(())
}

/// Utility method to create WireGuard keypair
#[must_use]
pub fn genkey() -> WireguardKey {
Expand Down
63 changes: 63 additions & 0 deletions crates/defguard_common/src/db/models/wireguard/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,66 @@ async fn test_can_assign_ips_multiple_addresses(_: PgPoolOptions, options: PgCon
Err(NetworkAddressError::IsBroadcastAddress(..))
);
}

// IPv4 /30 has 4 addresses: network (.0) + gateway (.1) + 1 host (.2) + broadcast (.3).
// IPv4 overhead = 3 (network + broadcast + gateway), so exactly 1 device fits.
#[test]
fn test_validate_network_size_ipv4_boundary() {
let network = WireguardNetwork::default()
.try_set_address("10.0.0.1/30")
.unwrap();

// 1 device + 3 overhead = 4 = size → fits
assert!(
network.validate_network_size(1).is_ok(),
"IPv4 /30 should fit 1 device"
);
// 2 devices + 3 overhead = 5 > 4 → does not fit
assert!(
network.validate_network_size(2).is_err(),
"IPv4 /30 should not fit 2 devices"
);
}

// IPv6 /126 has 4 addresses: fd00::0 (network) + fd00::1 (gateway) + fd00::2 + fd00::3.
// IPv6 has no broadcast, so overhead = 2 (network + gateway), and 2 devices fit.
#[test]
fn test_validate_network_size_ipv6_boundary() {
let network = WireguardNetwork::default()
.try_set_address("fd00::1/126")
.unwrap();

// 2 devices + 2 overhead = 4 = size → fits
assert!(
network.validate_network_size(2).is_ok(),
"IPv6 /126 should fit 2 devices"
);
// 3 devices + 2 overhead = 5 > 4 → does not fit
assert!(
network.validate_network_size(3).is_err(),
"IPv6 /126 should not fit 3 devices"
);
}

// Same subnet size (4 addresses), but IPv6 fits one more device than IPv4 because
// IPv6 has no broadcast address.
#[test]
fn test_validate_network_size_ipv4_vs_ipv6() {
let ipv4_net = WireguardNetwork::default()
.try_set_address("10.0.0.1/30")
.unwrap();
let ipv6_net = WireguardNetwork::default()
.try_set_address("fd00::1/126")
.unwrap();

// IPv4: 2 devices + 3 overhead = 5 > 4 → too small
assert!(
ipv4_net.validate_network_size(2).is_err(),
"IPv4 /30 should not fit 2 devices (no room after network+broadcast+gateway)"
);
// IPv6: 2 devices + 2 overhead = 4 = size → fits (no broadcast in IPv6)
assert!(
ipv6_net.validate_network_size(2).is_ok(),
"IPv6 /126 should fit 2 devices (no broadcast address)"
);
}
6 changes: 2 additions & 4 deletions crates/defguard_core/src/location_management/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,7 @@ pub(crate) async fn sync_location_allowed_devices(
.collect();

// Check if all devices can fit within network.
// Include network and broadcast addresses in the calculation.
let count = allowed_devices.len() + 3;
let count = allowed_devices.len();
location.validate_network_size(count)?;

// list all assigned IPs
Expand Down Expand Up @@ -134,8 +133,7 @@ pub(crate) async fn sync_allowed_devices_for_user(
.collect();

// Check if all devices can fit within network.
// Include network and broadcast addresses in the calculation.
let count = allowed_devices.len() + 3;
let count = allowed_devices.len();
location.validate_network_size(count)?;

// list all assigned IPs
Expand Down
25 changes: 19 additions & 6 deletions web/src/shared/utils/network.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { toNumber } from 'lodash-es';
import ipaddr from 'ipaddr.js';

export const smallestNetworkCapacity = (network_address: string): number => {
let maximal_cidr = 0;
let minCapacity = Infinity;
for (const address of network_address.split(',')) {
const cidr = toNumber(address.trim().split('/')[1]);
if (cidr > maximal_cidr) {
maximal_cidr = cidr;
const trimmed = address.trim();
try {
const [ip, prefix] = ipaddr.parseCIDR(trimmed);
let capacity: number;
if (ip.kind() === 'ipv4') {
capacity = 2 ** (32 - prefix) - 3;
} else {
// IPv6 has no broadcast address, so overhead is 2 (network + gateway)
const raw = 2 ** (128 - prefix) - 2;
capacity = Math.min(raw, Number.MAX_SAFE_INTEGER);
}
if (capacity < minCapacity) {
minCapacity = capacity;
}
} catch {
// unparseable entry — skip it
}
}
return 2 ** (32 - maximal_cidr) - 3;
return minCapacity === Infinity ? 0 : minCapacity;
};
56 changes: 56 additions & 0 deletions web/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { joinCsv, splitCsv } from '../src/shared/utils/csv';
import { smallestNetworkCapacity } from '../src/shared/utils/network';
import { formatFileName } from '../src/shared/utils/formatFileName';
import { formatIpForDisplay } from '../src/shared/utils/formatIpForDisplay';
import { removeEmptyStrings } from '../src/shared/utils/removeEmptyStrings';
Expand Down Expand Up @@ -298,3 +299,58 @@ describe('isValidDefguardUrl', () => {
expect(isValidDefguardUrl('ftp://')).toBe(false);
});
});


describe('smallestNetworkCapacity', () => {
// IPv4 cases
it('should return 253 for a single IPv4 /24', () => {
// 2^(32-24) - 3 = 256 - 3 = 253
expect(smallestNetworkCapacity('10.0.0.1/24')).toBe(253);
});

it('should return 1 for a single IPv4 /30', () => {
// 2^(32-30) - 3 = 4 - 3 = 1
expect(smallestNetworkCapacity('10.0.0.1/30')).toBe(1);
});

it('should return -1 for a single IPv4 /32 host address', () => {
// 2^(32-32) - 3 = 1 - 3 = -2... actually 0 hosts, 2^0=1, 1-3=-2
// but the function should be consistent: capacity < 0 means not usable
expect(smallestNetworkCapacity('10.0.0.1/32')).toBeLessThan(0);
});

// IPv6 cases
it('should return MAX_SAFE_INTEGER for a large IPv6 /64 subnet', () => {
// 2^(128-64) - 2 is astronomically large; must be capped
expect(smallestNetworkCapacity('fd00::1/64')).toBe(Number.MAX_SAFE_INTEGER);
});

it('should return 2 for a tiny IPv6 /126 subnet', () => {
// 2^(128-126) - 2 = 4 - 2 = 2
expect(smallestNetworkCapacity('fd00::1/126')).toBe(2);
});

it('should return 1 for an IPv6 /127 subnet', () => {
// 2^(128-127) - 2 = 2 - 2 = 0... hmm, actually 0.
// /127 has 2 addresses, no broadcast, gateway takes 1, so 1 usable
// Wait: 2^1 - 2 = 0. That means the formula gives 0 for /127.
// Let's use the same logic as IPv4: 2^(128-prefix) - 2
expect(smallestNetworkCapacity('fd00::1/127')).toBe(0);
});

it('should return negative for an IPv6 /128 host address', () => {
// 2^(128-128) - 2 = 1 - 2 = -1
expect(smallestNetworkCapacity('fd00::1/128')).toBe(-1);
});

// Mixed cases — should return the minimum capacity across all subnets
it('should return IPv4 capacity when IPv4 subnet is smaller', () => {
// IPv4 /30 → 1, IPv6 /64 → MAX_SAFE_INTEGER
expect(smallestNetworkCapacity('10.0.0.1/30, fd00::1/64')).toBe(1);
});

it('should return IPv6 capacity when IPv6 subnet is smaller', () => {
// IPv4 /24 → 253, IPv6 /126 → 2
expect(smallestNetworkCapacity('10.0.0.1/24, fd00::1/126')).toBe(2);
});
});
3 changes: 1 addition & 2 deletions web/tests/video-tutorials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { matchesVideoRouteContext } from '../src/shared/video-tutorials/resolved
import {
resolveSections,
resolveVideoGuidePlacement,
resolveVersion,
} from '../src/shared/video-tutorials/resolver';
import { canonicalizeRouteKey } from '../src/shared/video-tutorials/route-key';
import { parseVersion } from '../src/shared/video-tutorials/version';
import { parseVersion, resolveVersion } from '../src/shared/utils/resolveVersion';
import type { VideoTutorialsMappings } from '../src/shared/video-tutorials/types';

// ---------------------------------------------------------------------------
Expand Down
Loading