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
27 changes: 27 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,33 @@ Required Permission: `ip_range::delete`
Note: IP ranges with active IP assignments cannot be deleted. You must first remove all IP assignments before deleting
an IP range.

#### List Free IPs in Range

```
GET /api/admin/v1/ip_ranges/{id}/free_ips
```

Required Permission: `ip_range::view`

Returns a list of all unassigned (free) IP addresses in the specified IPv4 range.

**Limitations:**
- Only available for IPv4 ranges. IPv6 ranges are too large to enumerate and will return an error.
- Reserved IPs (gateway, network address, broadcast address) are excluded from the list.

Response:

```json
{
"data": [
"192.168.1.2",
"192.168.1.3",
"192.168.1.5",
"..."
]
}
```

### Access Policy Management

#### List Access Policies
Expand Down
5 changes: 5 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]

### Added
- **2026-02-21** - Added endpoint to list free IPs in an IPv4 range (Admin API)
- `GET /api/admin/v1/ip_ranges/{id}/free_ips` — Returns list of unassigned IP addresses
- Only available for IPv4 ranges; IPv6 ranges return an error (too large to enumerate)
- Excludes reserved IPs (gateway, network address, broadcast address)

- **2026-02-20** - Added `supported_currencies` to payment method configuration
- `AdminPaymentMethodConfigInfo` — New `supported_currencies` field (array of currency codes)
- `CreatePaymentMethodConfigRequest` — New optional `supported_currencies` field
Expand Down
24 changes: 24 additions & 0 deletions lnvps_api_admin/src/admin/ip_ranges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub fn router() -> Router<RouterState> {
.patch(admin_update_ip_range)
.delete(admin_delete_ip_range),
)
.route(
"/api/admin/v1/ip_ranges/{id}/free_ips",
get(admin_list_free_ips),
)
}

/// Validate and normalize gateway format for API responses.
Expand Down Expand Up @@ -349,3 +353,23 @@ async fn admin_delete_ip_range(

ApiData::ok(())
}

/// List free (unassigned) IPs in an IPv4 range.
/// Returns an error for IPv6 ranges since they're too large to enumerate.
async fn admin_list_free_ips(
auth: AdminAuth,
State(this): State<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<Vec<String>> {
// Check permission
auth.require_permission(AdminResource::IpRange, AdminAction::View)?;

// Use NetworkProvisioner to compute free IPs
let provisioner = NetworkProvisioner::new(this.db.clone());
let free_ips = provisioner.list_free_ips_in_range(id).await?;

// Convert IpAddr to String for API response
let free_ip_strings: Vec<String> = free_ips.iter().map(|ip| ip.to_string()).collect();

ApiData::ok(free_ip_strings)
}
215 changes: 215 additions & 0 deletions lnvps_api_common/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,71 @@ impl NetworkProvisioner {
.saturating_sub(assignment_count);
Some(available)
}

/// List all free (unassigned) IPs in an IPv4 range.
///
/// Returns an error for IPv6 ranges since they're too large to enumerate.
///
/// # Arguments
/// * `range_id` - The ID of the IP range to list free IPs for
///
/// # Returns
/// * `Ok(Vec<IpAddr>)` - List of free IP addresses
/// * `Err` - If the range is IPv6, doesn't exist, or has an invalid CIDR
pub async fn list_free_ips_in_range(&self, range_id: u64) -> Result<Vec<IpAddr>> {
let range = self.db.get_ip_range(range_id).await?;
Self::compute_free_ips(&range, &self.db).await
}

/// Compute the list of free IPs for a given IP range.
///
/// This is an internal method that takes the range and database reference.
/// Only works for IPv4 ranges.
async fn compute_free_ips(range: &IpRange, db: &Arc<dyn LNVpsDb>) -> Result<Vec<IpAddr>> {
let network: IpNetwork = range
.cidr
.parse()
.with_context(|| format!("Invalid CIDR format: {}", range.cidr))?;

// Only allow IPv4 ranges
if !network.is_ipv4() {
bail!(
"Free IP listing is only available for IPv4 ranges. IPv6 ranges are too large to enumerate."
);
}

// Get all assigned IPs in this range (non-deleted)
let assignments = db.list_vm_ip_assignments_in_range(range.id).await?;
let assigned_ips: HashSet<IpAddr> = assignments
.iter()
.filter_map(|a| a.ip.parse().ok())
.collect();

// Parse gateway to get reserved IPs
let gateway = parse_gateway(&range.gateway)?;

// Build set of reserved IPs
let mut reserved_ips: HashSet<IpAddr> = HashSet::new();
reserved_ips.insert(gateway.ip());

// If not using full range, reserve first and last IPs (network + broadcast)
if !range.use_full_range {
if let Some(first) = network.iter().next() {
reserved_ips.insert(first);
}
if let Some(last) = network.iter().last() {
reserved_ips.insert(last);
}
}

// Collect free IPs
let free_ips: Vec<IpAddr> = network
.iter()
.filter(|ip| !assigned_ips.contains(ip) && !reserved_ips.contains(ip))
.collect();

Ok(free_ips)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -471,4 +536,154 @@ mod tests {
"IP should have /64 prefix from gateway"
);
}

#[tokio::test]
async fn test_list_free_ips_basic() {
env_logger::try_init().ok();
let db = MockDb::default();

// Create a small /30 range (4 IPs total)
let range = IpRange {
id: 101,
cidr: "192.168.1.0/30".to_string(),
gateway: "192.168.1.1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::Sequential,
use_full_range: false,
..Default::default()
};

db.ip_range.lock().await.insert(101, range);

let db: Arc<dyn LNVpsDb> = Arc::new(db);
let mgr = NetworkProvisioner::new(db);

let free_ips = mgr.list_free_ips_in_range(101).await.unwrap();

// /30 has 4 IPs: .0, .1, .2, .3
// Reserved: .0 (network), .3 (broadcast), .1 (gateway)
// Free: .2
assert_eq!(free_ips.len(), 1);
assert_eq!(free_ips[0].to_string(), "192.168.1.2");
}

#[tokio::test]
async fn test_list_free_ips_with_assignments() {
env_logger::try_init().ok();
let db = MockDb::default();

// Create a /29 range (8 IPs total)
let range = IpRange {
id: 102,
cidr: "192.168.1.0/29".to_string(),
gateway: "192.168.1.1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::Sequential,
use_full_range: false,
..Default::default()
};

db.ip_range.lock().await.insert(102, range);

// Add some assignments
db.ip_assignments.lock().await.insert(
1,
VmIpAssignment {
id: 1,
vm_id: 1,
ip_range_id: 102,
ip: "192.168.1.2".to_string(),
..Default::default()
},
);
db.ip_assignments.lock().await.insert(
2,
VmIpAssignment {
id: 2,
vm_id: 2,
ip_range_id: 102,
ip: "192.168.1.4".to_string(),
..Default::default()
},
);

let db: Arc<dyn LNVpsDb> = Arc::new(db);
let mgr = NetworkProvisioner::new(db);

let free_ips = mgr.list_free_ips_in_range(102).await.unwrap();

// /29 has 8 IPs: .0, .1, .2, .3, .4, .5, .6, .7
// Reserved: .0 (network), .7 (broadcast), .1 (gateway)
// Assigned: .2, .4
// Free: .3, .5, .6
assert_eq!(free_ips.len(), 3);
let free_ip_strs: Vec<String> = free_ips.iter().map(|ip| ip.to_string()).collect();
assert!(free_ip_strs.contains(&"192.168.1.3".to_string()));
assert!(free_ip_strs.contains(&"192.168.1.5".to_string()));
assert!(free_ip_strs.contains(&"192.168.1.6".to_string()));
}

#[tokio::test]
async fn test_list_free_ips_use_full_range() {
env_logger::try_init().ok();
let db = MockDb::default();

// Create a /30 range with use_full_range=true
let range = IpRange {
id: 103,
cidr: "192.168.1.0/30".to_string(),
gateway: "192.168.1.1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::Sequential,
use_full_range: true,
..Default::default()
};

db.ip_range.lock().await.insert(103, range);

let db: Arc<dyn LNVpsDb> = Arc::new(db);
let mgr = NetworkProvisioner::new(db);

let free_ips = mgr.list_free_ips_in_range(103).await.unwrap();

// /30 has 4 IPs: .0, .1, .2, .3
// Reserved: .1 (gateway only)
// Free: .0, .2, .3
assert_eq!(free_ips.len(), 3);
let free_ip_strs: Vec<String> = free_ips.iter().map(|ip| ip.to_string()).collect();
assert!(free_ip_strs.contains(&"192.168.1.0".to_string()));
assert!(free_ip_strs.contains(&"192.168.1.2".to_string()));
assert!(free_ip_strs.contains(&"192.168.1.3".to_string()));
}

#[tokio::test]
async fn test_list_free_ips_ipv6_error() {
env_logger::try_init().ok();
let db = MockDb::default();

// Create an IPv6 range
let range = IpRange {
id: 104,
cidr: "2001:db8::/64".to_string(),
gateway: "2001:db8::1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::Sequential,
use_full_range: false,
..Default::default()
};

db.ip_range.lock().await.insert(104, range);

let db: Arc<dyn LNVpsDb> = Arc::new(db);
let mgr = NetworkProvisioner::new(db);

let result = mgr.list_free_ips_in_range(104).await;

assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("IPv4 ranges"));
}
}