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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
- Fix multicast subscriber tunnel source resolution for NAT environments — resolve local interface IP instead of using public IP
- Added multicast filters to access-pass list, enabling filtering by publisher/subscriber role and identifying access passes not authorized for a specific multicast group.
- Device interface `--bandwidth` and `--cir` flags now accept Kbps, Mbps, or Gbps units; `interface list` displays those values as human-readable strings
- Add duplicate IP check to prevent a user from assigning the same IP more than once
- Client
- Fix BGP `OnClose` deleting routes from all peers instead of only the closing peer, preventing multicast teardown from nuking unicast routes
- Skip route deletion on `OnClose` for `NoInstall` peers (multicast) since they never install kernel routes
Expand Down
2 changes: 1 addition & 1 deletion e2e/compatibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ var knownIncompatibilities = map[string]knownIncompat{

// All multicast operations that depend on multicast_group_create. When the group
// can't be created (< 0.8.1), these all fail with "MulticastGroup not found".
"write/multicast_group_wait_activated": {minVersion: "0.8.1"},
"write/multicast_group_wait_activated": {minVersion: "0.8.1"},
// multicast_group_update: In addition to the dependency above, v0.8.1-v0.8.8 parsed
// --max-bandwidth as a plain integer. v0.8.9 added validate_parse_bandwidth (a855ca7a)
// which accepts unit strings like "200Mbps".
Expand Down
2 changes: 1 addition & 1 deletion e2e/interface_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func TestE2E_InterfaceValidation(t *testing.T) {
output, err := dn.Manager.Exec(t.Context(), []string{
"doublezero", "device", "interface", "create",
testDeviceCode, testInterfaceName,
"--ip-net", "45.33.100.50/31",
"--ip-net", "198.51.100.1/31",
"--bandwidth", "10G",
})

Expand Down
145 changes: 144 additions & 1 deletion smartcontract/cli/src/device/interface/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::{
use clap::Args;
use doublezero_program_common::{types::network_v4::NetworkV4, validate_iface};
use doublezero_sdk::commands::device::{
get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand,
get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand, list::ListDeviceCommand,
};
use std::io::Write;

Expand Down Expand Up @@ -77,6 +77,26 @@ impl CreateDeviceInterfaceCliCommand {
))
})?;

if let Some(ref ip_net) = self.ip_net {
let devices = client.list_device(ListDeviceCommand)?;
for dev in devices.values() {
if dev.contributor_pk != device.contributor_pk {
continue;
}
for iface in &dev.interfaces {
let iface = iface.into_current_version();
if iface.ip_net == *ip_net {
eyre::bail!(
"IP {} is already assigned to interface {} on device {}",
ip_net,
iface.name,
dev.code
);
}
}
}
}

let (signature, _) = client.create_device_interface(CreateDeviceInterfaceCommand {
pubkey: device_pk,
name: self.name.clone(),
Expand Down Expand Up @@ -114,6 +134,129 @@ mod tests {
use mockall::predicate;
use solana_sdk::{pubkey::Pubkey, signature::Signature};

#[test]
fn test_cli_device_interface_create_fails_duplicate_ip_across_contributor_devices() {
use std::collections::HashMap;

let mut client = create_test_client();

let contributor_pk = Pubkey::new_unique();
let device1_pubkey = Pubkey::new_unique();
let device1 = Device {
account_type: AccountType::Device,
index: 1,
bump_seed: 255,
reference_count: 0,
code: "device1".to_string(),
contributor_pk,
location_pk: Pubkey::default(),
exchange_pk: Pubkey::new_unique(),
device_type: DeviceType::Hybrid,
public_ip: [1, 2, 3, 4].into(),
dz_prefixes: "1.2.3.4/32".parse().unwrap(),
status: DeviceStatus::Activated,
metrics_publisher_pk: Pubkey::default(),
owner: device1_pubkey,
mgmt_vrf: "default".to_string(),
interfaces: vec![],
max_users: 255,
users_count: 0,
device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers,
desired_status:
doublezero_serviceability::state::device::DeviceDesiredStatus::Activated,
unicast_users_count: 0,
multicast_users_count: 0,
max_unicast_users: 0,
max_multicast_users: 0,
};

let device2_pubkey = Pubkey::new_unique();
let device2 = Device {
account_type: AccountType::Device,
index: 2,
bump_seed: 254,
reference_count: 0,
code: "device2".to_string(),
contributor_pk,
location_pk: Pubkey::default(),
exchange_pk: Pubkey::new_unique(),
device_type: DeviceType::Hybrid,
public_ip: [5, 6, 7, 8].into(),
dz_prefixes: "5.6.7.8/32".parse().unwrap(),
status: DeviceStatus::Activated,
metrics_publisher_pk: Pubkey::default(),
owner: device2_pubkey,
mgmt_vrf: "default".to_string(),
interfaces: vec![CurrentInterfaceVersion {
status: InterfaceStatus::Activated,
name: "Loopback100".to_string(),
interface_type: InterfaceType::Loopback,
loopback_type: LoopbackType::Ipv4,
interface_cyoa: InterfaceCYOA::None,
interface_dia: InterfaceDIA::None,
bandwidth: 0,
cir: 0,
mtu: 1500,
routing_mode: RoutingMode::Static,
vlan_id: 0,
ip_net: "185.189.47.80/32".parse().unwrap(),
node_segment_idx: 0,
user_tunnel_endpoint: false,
}
.to_interface()],
max_users: 255,
users_count: 0,
device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers,
desired_status:
doublezero_serviceability::state::device::DeviceDesiredStatus::Activated,
unicast_users_count: 0,
multicast_users_count: 0,
max_unicast_users: 0,
max_multicast_users: 0,
};

let mut devices = HashMap::new();
devices.insert(device2_pubkey, device2);

client.expect_check_requirements().returning(|_| Ok(()));
client.expect_get_device().returning({
let device1 = device1.clone();
move |_| Ok((device1_pubkey, device1.clone()))
});
client
.expect_list_device()
.returning(move |_| Ok(devices.clone()));

let mut output = Vec::new();
let res = CreateDeviceInterfaceCliCommand {
device: device1_pubkey.to_string(),
name: "Loopback100".to_string(),
loopback_type: Some(types::LoopbackType::Ipv4),
interface_cyoa: None,
interface_dia: None,
ip_net: Some("185.189.47.80/32".parse().unwrap()),
bandwidth: 0,
cir: 0,
mtu: 1500,
routing_mode: types::RoutingMode::Static,
vlan_id: 0,
user_tunnel_endpoint: None,
wait: false,
}
.execute(&client, &mut output);

assert!(res.is_err());
let err = res.unwrap_err().to_string();
assert!(
err.contains("185.189.47.80/32"),
"Expected IP in error, got: {err}"
);
assert!(
err.contains("device2"),
"Expected device code in error, got: {err}"
);
}

#[test]
fn test_cli_device_interface_create() {
let mut client = create_test_client();
Expand Down
Loading
Loading