Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file.
- SDK now auto-detects the correct AccessPass PDA (static or dynamic) for allowlist operations based on whether an `allow_multiple_ip` pass exists
- Sentinel
- Make the multicast publisher worker's `--client-filter` flag repeatable so multiple validator client names can be matched in one run (OR semantics), matching the admin CLI behavior
- Set a concrete `tunnel_endpoint` on multicast publisher create, preferring a `user_tunnel_endpoint` interface IP and falling back to the device's `public_ip`, excluding IPs already in use by another user at the same `client_ip`

## [v0.18.0](https://github.com/malbeclabs/doublezero/compare/client/v0.17.0...client/v0.18.0) - 2026-04-17

Expand Down
79 changes: 76 additions & 3 deletions controlplane/doublezero-admin/src/cli/sentinel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use doublezero_sentinel::{
multicast_find::{apply_filters, FindFilters},
nearest_device::{device_proximity_score, find_nearest_device_for_multicast},
output::{print_table, OutputOptions},
tunnel_endpoint::select_tunnel_endpoint,
validator_metadata_reader::{
HttpValidatorMetadataReader, ValidatorMetadataReader, DEFAULT_VALIDATOR_METADATA_URL,
},
Expand Down Expand Up @@ -620,10 +621,13 @@ impl CreateValidatorMulticastPublishersCommand {

// Display plan (snapshot — target devices are re-evaluated at execution time).
eprintln!(
"\nWill create {} multicast publisher user(s) on group {}:\n",
"\nWill create {} multicast publisher user(s) on group {}:",
candidates.len(),
self.multicast_group,
);
eprintln!(
" user_type=Multicast cyoa_type=GREOverDIA publisher=true subscriber=false\n"
);
#[derive(Tabled, Serialize)]
struct PlanRow {
#[tabled(rename = "OWNER")]
Expand All @@ -634,6 +638,10 @@ impl CreateValidatorMulticastPublishersCommand {
device: String,
#[tabled(rename = "NEAREST DEVICE")]
nearest_device: String,
#[tabled(rename = "DEVICE IP")]
device_ip: String,
#[tabled(rename = "TUNNEL ENDPOINT")]
tunnel_endpoint: String,
#[tabled(rename = "CLIENT")]
client: String,
#[tabled(rename = "STAKE (SOL)")]
Expand All @@ -643,11 +651,12 @@ impl CreateValidatorMulticastPublishersCommand {
let plan_rows: Vec<PlanRow> = candidates
.iter()
.map(|c| {
let nearest = match find_nearest_device_for_multicast(
let target_device = find_nearest_device_for_multicast(
&c.device_pk,
&device_infos,
latency_map.as_ref(),
) {
);
let nearest = match target_device {
None => "none".to_string(),
Some(d) if d.pk == c.device_pk => d.code.clone(),
Some(d) => {
Expand All @@ -658,11 +667,26 @@ impl CreateValidatorMulticastPublishersCommand {
fmt_nearest_label(&d.code, score, self.nearest_via_geo)
}
};
let device_ip = target_device
.map(|d| d.public_ip.to_string())
.unwrap_or_else(|| "-".to_string());
let tunnel_endpoint = target_device
.map(|d| {
let exclude = tunnel_exclude_ips(&all_users, c.client_ip, &device_infos);
select_tunnel_endpoint(d.public_ip, &d.user_tunnel_endpoints, &exclude)
})
.unwrap_or(Ipv4Addr::UNSPECIFIED);
PlanRow {
owner: c.owner.to_string(),
client_ip: c.client_ip.to_string(),
device: c.device_label.clone(),
nearest_device: nearest,
device_ip,
tunnel_endpoint: if tunnel_endpoint == Ipv4Addr::UNSPECIFIED {
"(activator-assigned)".to_string()
} else {
tunnel_endpoint.to_string()
},
client: c.software_client.clone(),
stake_sol: format!("{:.2}", c.stake_sol),
}
Expand Down Expand Up @@ -729,14 +753,35 @@ impl CreateValidatorMulticastPublishersCommand {
tenant_pk: Pubkey::default(),
user_type: doublezero_sdk::UserType::IBRL,
publishers: vec![],
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
};

let tunnel_endpoint = device_infos
.get(&target_device_pk)
.map(|d| {
let exclude =
tunnel_exclude_ips(&all_users, candidate.client_ip, &device_infos);
select_tunnel_endpoint(d.public_ip, &d.user_tunnel_endpoints, &exclude)
})
.unwrap_or(Ipv4Addr::UNSPECIFIED);

if tunnel_endpoint == Ipv4Addr::UNSPECIFIED {
eprintln!(
" Error: no tunnel endpoint available on device {} for {} — all public_ip \
and user_tunnel_endpoint IPs are already in use by this client_ip. Skipping.",
target_device_label, candidate.client_ip,
);
skipped += 1;
continue;
}

let ixs = match build_create_multicast_publisher_instructions(
&program_id,
&payer_pk,
&candidate.owner,
&multicast_group_pk,
&dz_user,
tunnel_endpoint,
) {
Ok(ixs) => ixs,
Err(e) => {
Expand Down Expand Up @@ -798,6 +843,34 @@ impl CreateValidatorMulticastPublishersCommand {
// Helpers
// ---------------------------------------------------------------------------

/// Build the set of tunnel endpoints already in use by users at `client_ip`.
///
/// For users with an explicit `tunnel_endpoint` onchain, use that value.
/// For legacy users where `tunnel_endpoint == UNSPECIFIED`, the activator
/// implicitly routes their tunnel through the device's `public_ip`, so
/// resolve it from `device_infos` rather than dropping the entry.
fn tunnel_exclude_ips(
users: &[DzUser],
client_ip: Ipv4Addr,
device_infos: &HashMap<Pubkey, DzDeviceInfo>,
) -> Vec<Ipv4Addr> {
users
.iter()
.filter(|u| u.client_ip == client_ip)
.map(|u| {
if u.tunnel_endpoint != Ipv4Addr::UNSPECIFIED {
u.tunnel_endpoint
} else {
device_infos
.get(&u.device_pk)
.map(|d| d.public_ip)
.unwrap_or(Ipv4Addr::UNSPECIFIED)
}
})
.filter(|ip| *ip != Ipv4Addr::UNSPECIFIED)
.collect()
}

/// Format a nearest-device label with its proximity score.
/// Shows `"code (1234 µs)"` in latency mode or `"code (365 km)"` in geo mode.
/// Falls back to plain `code` when the score is infinite (no latency data).
Expand Down
19 changes: 19 additions & 0 deletions crates/sentinel/src/dz_ledger_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct DzUser {
pub tenant_pk: Pubkey,
pub user_type: UserType,
pub publishers: Vec<Pubkey>,
pub tunnel_endpoint: Ipv4Addr,
}

/// Maps device pubkey → device code.
Expand All @@ -47,6 +48,9 @@ pub struct DzDeviceInfo {
pub reserved_seats: u16,
pub multicast_publishers_count: u16,
pub max_multicast_publishers: u16,
pub public_ip: Ipv4Addr,
/// IPs from device interfaces where `user_tunnel_endpoint == true`.
pub user_tunnel_endpoints: Vec<Ipv4Addr>,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -120,6 +124,7 @@ impl DzLedgerReader for RpcDzLedgerReader {
tenant_pk: user.tenant_pk,
user_type: user.user_type,
publishers: user.publishers.clone(),
tunnel_endpoint: user.tunnel_endpoint,
});
}

Expand Down Expand Up @@ -276,6 +281,18 @@ pub fn fetch_device_infos(
.get(&device.location_pk)
.copied()
.unwrap_or((0.0, 0.0));
let user_tunnel_endpoints = device
.interfaces
.iter()
.filter_map(|iface| {
let iface = iface.into_current_version();
if iface.user_tunnel_endpoint && iface.ip_net != Default::default() {
Some(iface.ip_net.ip())
} else {
None
}
})
.collect();
infos.insert(
pk,
DzDeviceInfo {
Expand All @@ -288,6 +305,8 @@ pub fn fetch_device_infos(
reserved_seats: device.reserved_seats,
multicast_publishers_count: device.multicast_publishers_count,
max_multicast_publishers: device.max_multicast_publishers,
public_ip: device.public_ip,
user_tunnel_endpoints,
},
);
}
Expand Down
9 changes: 8 additions & 1 deletion crates/sentinel/src/dz_ledger_writer.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::net::Ipv4Addr;

use anyhow::{Context, Result};
use doublezero_serviceability::{
instructions::DoubleZeroInstruction,
Expand Down Expand Up @@ -36,6 +38,7 @@ pub fn build_create_multicast_publisher_instructions(
owner: &Pubkey,
multicast_group_pk: &Pubkey,
user: &DzUser,
tunnel_endpoint: Ipv4Addr,
) -> Result<CreateMulticastPublisherInstructions> {
let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, owner);
let (globalstate_pda, _) = get_globalstate_pda(program_id);
Expand Down Expand Up @@ -84,7 +87,7 @@ pub fn build_create_multicast_publisher_instructions(
client_ip: user.client_ip,
publisher: true,
subscriber: false,
tunnel_endpoint: std::net::Ipv4Addr::UNSPECIFIED,
tunnel_endpoint,
dz_prefix_count: 0,
owner: *owner,
}),
Expand Down Expand Up @@ -139,6 +142,7 @@ mod tests {
tenant_pk: Pubkey::default(),
user_type: UserType::IBRL,
publishers: vec![],
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
};

let owner = Pubkey::new_unique();
Expand All @@ -149,6 +153,7 @@ mod tests {
&owner,
&multicast_group,
&user,
Ipv4Addr::UNSPECIFIED,
)
.unwrap();

Expand Down Expand Up @@ -182,6 +187,7 @@ mod tests {
tenant_pk: Pubkey::default(),
user_type: UserType::IBRL,
publishers: vec![],
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
};

let owner = Pubkey::new_unique();
Expand All @@ -192,6 +198,7 @@ mod tests {
&owner,
&multicast_group,
&user,
Ipv4Addr::UNSPECIFIED,
)
.unwrap();

Expand Down
1 change: 1 addition & 0 deletions crates/sentinel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pub mod multicast_publisher;
pub mod nearest_device;
pub mod output;
pub mod settings;
pub mod tunnel_endpoint;
pub mod validator_metadata_reader;
2 changes: 2 additions & 0 deletions crates/sentinel/src/multicast_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ mod tests {
tenant_pk: Pubkey::default(),
user_type: UserType::IBRL,
publishers: vec![],
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
}
}

Expand All @@ -149,6 +150,7 @@ mod tests {
tenant_pk: Pubkey::default(),
user_type: UserType::Multicast,
publishers: groups,
tunnel_endpoint: Ipv4Addr::UNSPECIFIED,
}
}

Expand Down
Loading
Loading