diff --git a/crates/api-core/src/dhcp/discover.rs b/crates/api-core/src/dhcp/discover.rs index 34a8a9b018..c9a700ccb2 100644 --- a/crates/api-core/src/dhcp/discover.rs +++ b/crates/api-core/src/dhcp/discover.rs @@ -15,22 +15,26 @@ * limitations under the License. */ use std::net::{IpAddr, Ipv4Addr}; -use std::str::FromStr; use ::rpc::forge as rpc; use carbide_network::ip::{IdentifyAddressFamily, IpAddressFamily}; use db::dhcp_entry::DhcpEntry; use db::{self, expected_machine, machine_interface}; use mac_address::MacAddress; +use model::allocation_type::AllocationType; use model::dpa_interface::DpaInterface; use model::expected_machine::ExpectedHostNic; +use model::machine::MachineInterfaceSnapshot; use model::machine_interface::InterfaceType; -use model::network_segment::{AllocationStrategy, NetworkSegmentSearchConfig, NetworkSegmentType}; +use model::network_segment::{ + AllocationStrategy, NetworkSegment, NetworkSegmentSearchConfig, NetworkSegmentType, +}; use sqlx::PgConnection; use tonic::{Request, Response}; use crate::CarbideError; use crate::api::Api; +use crate::dhcp::v6; // MTU for both the underlay and overlay networks on // the E/W Fabric @@ -43,6 +47,264 @@ fn get_relay_from_desired(desired: Ipv4Addr) -> Ipv4Addr { Ipv4Addr::from(relay_u32) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DhcpMessageKind { + V4Discover, + V6Solicit, + V6Request, + V6InfoRequest, +} + +/// Validate DHCP protocol fields and return the internal routing values. +fn parse_discovery_protocol( + address_family: Option, + message_kind: Option, + inferred_family: IpAddressFamily, + duid: Option<&[u8]>, +) -> Result<(IpAddressFamily, Option), CarbideError> { + let (address_family, message_kind) = match (address_family, message_kind) { + // Legacy callers omit both fields and are IPv4-only. + (None, None) => { + if inferred_family == IpAddressFamily::Ipv6 { + return Err(CarbideError::InvalidArgument( + "address_family and message_kind are required for DHCPv6".to_string(), + )); + } + if duid.is_some() { + return Err(CarbideError::InvalidArgument( + "duid is invalid for DHCPv4 requests".to_string(), + )); + } + return Ok((inferred_family, None)); + } + (Some(_), None) | (None, Some(_)) => { + return Err(CarbideError::InvalidArgument( + "address_family and message_kind must be provided together".to_string(), + )); + } + (Some(address_family), Some(message_kind)) => (address_family, message_kind), + }; + + let address_family = rpc::AddressFamily::try_from(address_family).map_err(|_| { + CarbideError::InvalidArgument("unknown address_family or message_kind".to_string()) + })?; + let message_kind = rpc::MessageKind::try_from(message_kind).map_err(|_| { + CarbideError::InvalidArgument("unknown address_family or message_kind".to_string()) + })?; + + // Explicit unspecified values are invalid; callers should omit both fields for legacy v4. + if address_family == rpc::AddressFamily::Unspecified + || message_kind == rpc::MessageKind::Unspecified + { + return Err(CarbideError::InvalidArgument( + "address_family and message_kind must be specified".to_string(), + )); + } + + let declared_family = match address_family { + rpc::AddressFamily::V4 => IpAddressFamily::Ipv4, + rpc::AddressFamily::V6 => IpAddressFamily::Ipv6, + _ => { + return Err(CarbideError::InvalidArgument( + "unknown address_family or message_kind".to_string(), + )); + } + }; + + if declared_family != inferred_family { + return Err(CarbideError::InvalidArgument( + "address_family must match relay/link-address family".to_string(), + )); + } + + // Route only compatible family/kind pairs. + match (address_family, message_kind) { + (rpc::AddressFamily::V4, rpc::MessageKind::V4Discover) => { + if duid.is_some() { + return Err(CarbideError::InvalidArgument( + "duid is invalid for DHCPv4 requests".to_string(), + )); + } + Ok((IpAddressFamily::Ipv4, Some(DhcpMessageKind::V4Discover))) + } + (rpc::AddressFamily::V4, _) => Err(CarbideError::InvalidArgument( + "ADDRESS_FAMILY_V4 requires MESSAGE_KIND_V4_DISCOVER".to_string(), + )), + (rpc::AddressFamily::V6, rpc::MessageKind::V6Solicit) => { + require_dhcpv6_duid(duid)?; + Ok((IpAddressFamily::Ipv6, Some(DhcpMessageKind::V6Solicit))) + } + (rpc::AddressFamily::V6, rpc::MessageKind::V6Request) => { + require_dhcpv6_duid(duid)?; + Ok((IpAddressFamily::Ipv6, Some(DhcpMessageKind::V6Request))) + } + (rpc::AddressFamily::V6, rpc::MessageKind::V6InfoRequest) => { + require_dhcpv6_duid(duid)?; + Ok((IpAddressFamily::Ipv6, Some(DhcpMessageKind::V6InfoRequest))) + } + (rpc::AddressFamily::V6, _) => Err(CarbideError::InvalidArgument( + "ADDRESS_FAMILY_V6 requires a DHCPv6 message_kind".to_string(), + )), + _ => Err(CarbideError::InvalidArgument( + "unknown address_family or message_kind".to_string(), + )), + } +} + +/// Ensure a DHCPv6 request carries a non-empty DUID. +fn require_dhcpv6_duid(duid: Option<&[u8]>) -> Result<(), CarbideError> { + if duid.is_some_and(|duid| !duid.is_empty()) { + Ok(()) + } else { + Err(CarbideError::MissingArgument("duid")) + } +} + +/// Ensure the selected segment is enabled for DHCPv6. +fn ensure_dhcpv6_enabled(segment: &NetworkSegment) -> Result<(), CarbideError> { + // A segment must carry at least one IPv6 prefix to serve DHCPv6 options. + if segment + .prefixes + .iter() + .any(|prefix| prefix.prefix.is_ipv6()) + { + Ok(()) + } else { + Err(CarbideError::FailedPrecondition(format!( + "DHCPv6 request received for network segment {} without an IPv6 prefix", + segment.id + ))) + } +} + +/// Build an options-only DHCPv6 response for a segment without observing an interface. +async fn options_only_dhcpv6_record_from_segment( + txn: &mut PgConnection, + mac_address: MacAddress, + segment: &NetworkSegment, + ntp_servers: &[Ipv4Addr], +) -> Result { + // Preserve the cache invalidation marker returned by normal DHCP records. + let last_invalidation_time = db::dhcp_record::last_invalidation_time(&mut *txn).await?; + + Ok(rpc::DhcpRecord { + machine_id: None, + machine_interface_id: None, + segment_id: Some(segment.id), + subdomain_id: segment.config.subdomain_id, + fqdn: String::new(), + mac_address: mac_address.to_string(), + address: String::new(), + mtu: segment.config.mtu, + prefix: String::new(), + gateway: None, + booturl: None, + last_invalidation_time: Some(last_invalidation_time.into()), + ntp_servers: ntp_servers.iter().map(ToString::to_string).collect(), + }) +} + +/// Build an options-only DHCPv6 response from interface metadata. +async fn options_only_dhcpv6_record_from_interface( + txn: &mut PgConnection, + machine_interface: &MachineInterfaceSnapshot, + segment: &NetworkSegment, + ntp_servers: &[Ipv4Addr], +) -> Result { + // Resolve FQDN metadata from the interface domain when one is attached. + let fqdn_domain_id = machine_interface.domain_id.or(segment.config.subdomain_id); + let fqdn = if let Some(domain_id) = fqdn_domain_id { + let domain = db::dns::domain::find_by_uuid(&mut *txn, domain_id) + .await? + .ok_or_else(|| CarbideError::NotFoundError { + kind: "domain", + id: domain_id.to_string(), + })?; + format!("{}.{}", machine_interface.hostname, domain.name) + } else { + String::new() + }; + + // Preserve the cache invalidation marker returned by normal DHCP records. + let last_invalidation_time = db::dhcp_record::last_invalidation_time(&mut *txn).await?; + + Ok(rpc::DhcpRecord { + machine_id: machine_interface.machine_id, + machine_interface_id: Some(machine_interface.id), + segment_id: Some(segment.id), + subdomain_id: segment.config.subdomain_id, + fqdn, + mac_address: machine_interface.mac_address.to_string(), + address: String::new(), + mtu: segment.config.mtu, + prefix: String::new(), + gateway: None, + booturl: None, + last_invalidation_time: Some(last_invalidation_time.into()), + ntp_servers: ntp_servers.iter().map(ToString::to_string).collect(), + }) +} + +/// Ensure a stateful DHCP allocation exists for the requested family. +/// +/// DHCPv6 stateful allocations are authoritative over prior SLAAC observations: +/// both are IPv6 rows on the same interface, so the existing unique family index +/// requires replacing an observed SLAAC row before allocating the DHCP lease. +async fn ensure_dhcp_address_for_family( + txn: &mut PgConnection, + machine_interface: &MachineInterfaceSnapshot, + segment: &NetworkSegment, + parsed_mac: MacAddress, + address_family: IpAddressFamily, +) -> Result<(), CarbideError> { + let existing_allocation_type = db::machine_interface_address::find_allocation_type_for_family( + &mut *txn, + machine_interface.id, + address_family, + ) + .await?; + + match existing_allocation_type { + None => {} + Some(AllocationType::Slaac) if address_family == IpAddressFamily::Ipv6 => { + db::machine_interface_address::delete_by_interface_family( + &mut *txn, + machine_interface.id, + address_family, + AllocationType::Slaac, + ) + .await?; + } + Some(_) => return Ok(()), + } + + tracing::info!( + interface_id = %machine_interface.id, + %parsed_mac, + ?address_family, + "Interface missing DHCP address for family, allocating from segment" + ); + + // If the segment only allows static reservations, don't dynamically + // allocate. The device has no reservation. + if segment.config.allocation_strategy == AllocationStrategy::Reserved { + return Err(CarbideError::internal(format!( + "segment {} configured for static DHCP leases only; no static reservation for MAC {parsed_mac}", + segment.config.name, + ))); + } + + db::machine_interface::allocate_address_for_family( + txn, + machine_interface.id, + segment, + address_family, + ) + .await?; + + Ok(()) +} + // Overlay IP address request from DPA. DPA tells us // what IP address it wants (calculated algorithmically // from the underlay IP address). So we just allocate @@ -82,8 +344,6 @@ async fn handle_overlay_from_dpa( fqdn: String::new(), prefix, ntp_servers: ntp_servers.iter().map(ToString::to_string).collect(), - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, }))) } @@ -97,7 +357,7 @@ async fn handle_underlay_from_dpa( ntp_servers: &[Ipv4Addr], ) -> Result>, CarbideError> { // The relay address and the mac address should differ only in bit 0 - let relay_addr = Ipv4Addr::from_str(&relay_address)?; + let relay_addr = relay_address.parse::()?; let ip_u32 = u32::from(relay_addr); @@ -125,8 +385,6 @@ async fn handle_underlay_from_dpa( fqdn: String::new(), prefix, ntp_servers: ntp_servers.iter().map(ToString::to_string).collect(), - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, }))) } @@ -196,15 +454,24 @@ pub async fn discover_dhcp( link_address, vendor_string, desired_address, + address_family, + message_kind, + duid, .. } = request.into_inner(); - // Use link address if present, else relay address. Link address represents subnet address at - // first router. + // Select the segment lookup key once. DHCPv6 uses Relay-Forward link-address + // when present, so all segment lookups and predicted promotion use this value. let address_to_use_for_dhcp = link_address.as_ref().unwrap_or(&relay_address); - let parsed_relay = address_to_use_for_dhcp.parse()?; - let relay_ip = IpAddr::from_str(&relay_address)?; - let address_family = relay_ip.address_family(); + let parsed_relay: IpAddr = address_to_use_for_dhcp.parse()?; + let (address_family, message_kind) = parse_discovery_protocol( + address_family, + message_kind, + parsed_relay.address_family(), + duid.as_deref(), + )?; + let is_v6_observation = address_family == IpAddressFamily::Ipv6 + && message_kind == Some(DhcpMessageKind::V6InfoRequest); let mut host_nic: Option = None; // `is_primary_nic` reflects the matched ExpectedHostNic's `primary` flag. // - `Some(true)` -- the operator flagged this NIC as the host's boot interface. @@ -213,9 +480,13 @@ pub async fn discover_dhcp( let mut is_primary_nic: Option = None; let parsed_mac: MacAddress = mac_address.parse()?; + let mut predicted_interface_for_observation = None; - let desired_address_ip: Option = - desired_address.map(|addr| addr.parse()).transpose()?; + let desired_address_ip: Option = if is_v6_observation { + None + } else { + desired_address.map(|addr| addr.parse()).transpose()? + }; let existing_machine_id = match db::machine::find_existing_machine(&mut txn, parsed_mac, parsed_relay).await? { @@ -225,15 +496,20 @@ pub async fn discover_dhcp( db::predicted_machine_interface::find_by_mac_address(&mut txn, parsed_mac) .await? { - // remember expected machine id for later rack update - machine_interface::move_predicted_machine_interface_to_machine( - &mut txn, - &expected_interface, - relay_ip, - api.runtime_config.retained_boot_interface_window, - ) - .await?; - Some(expected_interface.machine_id) + if is_v6_observation { + predicted_interface_for_observation = Some(expected_interface); + None + } else { + // remember expected machine id for later rack update + machine_interface::move_predicted_machine_interface_to_machine( + &mut txn, + &expected_interface, + parsed_relay, + api.runtime_config.retained_boot_interface_window, + ) + .await?; + Some(expected_interface.machine_id) + } } else { // DPA allocation is currently IPv4-only. The overlay // uses u32 arithmetic (LSB toggle) and /31 linknets, @@ -284,6 +560,7 @@ pub async fn discover_dhcp( .cloned(); if let Some(ref nic) = host_nic && let Some(fixed_ip) = nic.fixed_ip + && fixed_ip.is_address_family(address_family) { // It looks like there's a DHCP reservation for this address, // so make an idempotent call to ensure we have a preallocated @@ -302,6 +579,7 @@ pub async fn discover_dhcp( .await .map_err(CarbideError::from)? && let Some(bmc_ip) = m.data.bmc_ip_address + && bmc_ip.is_address_family(address_family) { // In this case it looks like our parsed MAC address is for the BMC // of an expected machine, and it has a static DHCP reservation per @@ -321,6 +599,7 @@ pub async fn discover_dhcp( .await .map_err(CarbideError::from)? && let Some(nvos_ip) = s.nvos_ip_address + && nvos_ip.is_address_family(address_family) { // The parsed MAC matches the single wired NVOS port of an expected // switch with a configured static IP. Mirrors the ExpectedHostNic @@ -342,16 +621,143 @@ pub async fn discover_dhcp( } }; - let machine_interface = db::machine_interface::find_or_create_machine_interface( - &mut txn, - existing_machine_id, - parsed_mac, - std::slice::from_ref(&parsed_relay), - host_nic, - is_primary_nic, - api.runtime_config.retained_boot_interface_window, - ) - .await?; + if is_v6_observation { + let network_segments = db::machine_interface::network_segments_for_dhcp_relays( + &mut txn, + std::slice::from_ref(&parsed_relay), + host_nic.as_ref(), + ) + .await?; + let exact_link_address_match = |segment: &NetworkSegment| { + segment + .prefixes + .iter() + .any(|prefix| prefix.dhcpv6_link_address == Some(parsed_relay)) + }; + let reserved_segment = |segment: &NetworkSegment| { + segment.config.allocation_strategy == AllocationStrategy::Reserved + }; + + // Exact DHCPv6 link-address matches are authoritative. Only prefer + // reserved segments within that exact-match subset; prefix candidates + // are fallback routing context. + let segment = network_segments + .iter() + .filter(|&segment| exact_link_address_match(segment)) + .find(|&segment| reserved_segment(segment)) + .or_else(|| { + network_segments + .iter() + .find(|&segment| exact_link_address_match(segment)) + }) + .or_else(|| { + // Prefix-overlap routing is intentionally not resolved here. If + // no exact DHCPv6 link-address match exists, prefer a reserved + // candidate so anonymous INFO_REQUESTs can receive options-only + // metadata instead of creating an observed row on an ambiguous + // dynamic prefix. + network_segments + .iter() + .find(|&segment| reserved_segment(segment)) + }) + .or_else(|| network_segments.first()) + .ok_or_else(|| { + CarbideError::internal(format!( + "No network segment defined for DHCPv6 relay address {parsed_relay}" + )) + })?; + ensure_dhcpv6_enabled(segment)?; + + let interfaces = db::machine_interface::find_by_mac_address(&mut txn, parsed_mac).await?; + let has_cross_segment_interface = interfaces + .iter() + .any(|interface| interface.segment_id != segment.id); + if has_cross_segment_interface { + // Do not turn a wrong-segment known MAC into config-only success. + // Fall through so the existing global MAC guard rejects or handles + // static-assignment moves exactly as IPv4/stateful DHCP does. + tracing::debug!( + %parsed_mac, + segment_id = %segment.id, + "DHCPv6 options request will use global MAC segment reconciliation" + ); + } else if segment.config.allocation_strategy == AllocationStrategy::Reserved + && interfaces.is_empty() + && predicted_interface_for_observation.is_none() + { + // Anonymous reserved requests can receive segment options without + // creating observed rows. Known or predicted interfaces must + // continue through common safety checks and DHCP bookkeeping. + let record = options_only_dhcpv6_record_from_segment( + &mut txn, + parsed_mac, + segment, + &api.runtime_config.ntp_servers, + ) + .await?; + txn.commit().await?; + return Ok(Response::new(record)); + } + } + + if is_v6_observation && predicted_interface_for_observation.is_some() { + let interfaces = db::machine_interface::find_by_mac_address(&mut txn, parsed_mac).await?; + if interfaces.is_empty() + && let Some(predicted_interface) = predicted_interface_for_observation.take() + { + // Reserved segments reject anonymous observed-row creation, but a + // prediction is explicit host identity. Promote it before the observed + // helper so the common safety checks and DHCP bookkeeping still run. + machine_interface::move_predicted_machine_interface_to_machine( + &mut txn, + &predicted_interface, + parsed_relay, + api.runtime_config.retained_boot_interface_window, + ) + .await?; + } + } + + let mut machine_interface = if is_v6_observation { + // INFORMATION-REQUEST observes identity only; it must not consume a DHCP lease. + db::machine_interface::find_or_create_observed_machine_interface( + &mut txn, + existing_machine_id, + parsed_mac, + std::slice::from_ref(&parsed_relay), + host_nic, + is_primary_nic, + api.runtime_config.retained_boot_interface_window, + ) + .await? + } else { + // First-contact stateful DHCP needs candidate-segment fallback, but only + // for the requested address family. + db::machine_interface::find_or_create_machine_interface_for_family( + &mut txn, + existing_machine_id, + parsed_mac, + std::slice::from_ref(&parsed_relay), + machine_interface::FindOrCreateMachineInterfaceOptions { + host_nic, + is_primary: is_primary_nic, + retained_window: api.runtime_config.retained_boot_interface_window, + }, + address_family, + ) + .await? + }; + + if let Some(predicted_interface) = predicted_interface_for_observation { + machine_interface::move_predicted_machine_interface_to_machine( + &mut txn, + &predicted_interface, + parsed_relay, + api.runtime_config.retained_boot_interface_window, + ) + .await?; + machine_interface = db::machine_interface::find_one(&mut txn, machine_interface.id).await?; + } // Use the interface's actual segment, not only relay context, so // dormant admin interfaces cannot keep serving stale DHCP leases. @@ -366,6 +772,11 @@ pub async fn discover_dhcp( kind: "network_segment", id: machine_interface.segment_id.to_string(), })?; + + if address_family == IpAddressFamily::Ipv6 { + ensure_dhcpv6_enabled(&segment)?; + } + // Only DPU-backed host admin links are dormant when non-primary. Other non-primary admin // interfaces can be valid operator-declared host NICs and must still be allowed to DHCP. let is_dpu_backed_host_admin_interface = machine_interface.attached_dpu_machine_id.is_some() @@ -380,36 +791,14 @@ pub async fn discover_dhcp( ))); } - // If the interface has no address for the requested address family - // (e.g., after a lease expiration cleaned up the DHCP allocation, - // or this is a new address family for a dual-stack interface), - // re-allocate from the segment. - if !db::machine_interface_address::has_address_for_family( - &mut txn, - machine_interface.id, - address_family, - ) - .await? - { - tracing::info!( - interface_id = %machine_interface.id, - %parsed_mac, - ?address_family, - "Interface missing address for family, re-allocating from segment" - ); - // If the segment only allows static reservations, don't - // dynamically allocate. The device has no reservation. - if segment.config.allocation_strategy == AllocationStrategy::Reserved { - return Err(CarbideError::internal(format!( - "segment {} configured for static DHCP leases only; no static reservation for MAC {parsed_mac}", - segment.config.name, - ))); - } - - db::machine_interface::allocate_address_for_family( + if is_v6_observation { + v6::observe_slaac_address(&mut txn, machine_interface.id, &segment, &parsed_mac).await?; + } else { + ensure_dhcp_address_for_family( &mut txn, - machine_interface.id, + &machine_interface, &segment, + parsed_mac, address_family, ) .await?; @@ -453,8 +842,27 @@ pub async fn discover_dhcp( db::machine_interface::update_last_dhcp(&mut txn, machine_interface.id, None).await?; + let options_only_record = if is_v6_observation { + machine_interface = db::machine_interface::find_one(&mut txn, machine_interface.id).await?; + Some( + options_only_dhcpv6_record_from_interface( + &mut txn, + &machine_interface, + &segment, + &api.runtime_config.ntp_servers, + ) + .await?, + ) + } else { + None + }; + txn.commit().await?; + if let Some(record) = options_only_record { + return Ok(Response::new(record)); + } + let mut txn = api.txn_begin().await?; let mut record: rpc::DhcpRecord = db::dhcp_record::find_by_mac_address( diff --git a/crates/api-core/src/dhcp/mod.rs b/crates/api-core/src/dhcp/mod.rs index 3634766a6f..dc0e5820fa 100644 --- a/crates/api-core/src/dhcp/mod.rs +++ b/crates/api-core/src/dhcp/mod.rs @@ -17,3 +17,4 @@ pub mod discover; pub mod expire; +mod v6; diff --git a/crates/api-core/src/dhcp/v6.rs b/crates/api-core/src/dhcp/v6.rs new file mode 100644 index 0000000000..2fe44d6aa4 --- /dev/null +++ b/crates/api-core/src/dhcp/v6.rs @@ -0,0 +1,164 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//! DHCPv6 SLAAC helpers used by `crate::dhcp::discover`. + +use std::net::{IpAddr, Ipv6Addr}; + +use carbide_network::ip::IpAddressFamily; +use carbide_uuid::machine::MachineInterfaceId; +use ipnetwork::IpNetwork; +use mac_address::MacAddress; +use model::allocation_type::AllocationType; +use model::network_segment::NetworkSegment; +use sqlx::PgConnection; + +use crate::CarbideError; + +/// Compute the SLAAC address formed by applying RFC 4291 EUI-64 to `mac`. +/// +/// Returns `None` unless `prefix` is an IPv6 /64, because modified EUI-64 +/// interface identifiers are defined for 64-bit subnet prefixes. +pub fn slaac_gua_from_eui64(prefix: &IpNetwork, mac: &MacAddress) -> Option { + // SLAAC EUI-64 is only meaningful for IPv6 /64 prefixes. + let IpNetwork::V6(prefix) = prefix else { + return None; + }; + if prefix.prefix() != 64 { + return None; + } + + // Copy the network bits, then append the modified EUI-64 identifier. + let mac = mac.bytes(); + let mut octets = prefix.network().octets(); + octets[8] = mac[0] ^ 0x02; + octets[9] = mac[1]; + octets[10] = mac[2]; + octets[11] = 0xff; + octets[12] = 0xfe; + octets[13] = mac[3]; + octets[14] = mac[4]; + octets[15] = mac[5]; + + Some(Ipv6Addr::from(octets)) +} + +/// Record one SLAAC observation for an interface when the segment is eligible. +/// +/// A row is inserted only when the segment has exactly one IPv6 prefix and that +/// prefix is /64. Stateful DHCPv6 or static IPv6 assignments therefore +/// suppress SLAAC observation. Reserved segments serve DHCPv6 options without +/// creating observed SLAAC rows. +pub async fn observe_slaac_address( + txn: &mut PgConnection, + interface_id: MachineInterfaceId, + segment: &NetworkSegment, + mac: &MacAddress, +) -> Result<(), CarbideError> { + // Segment-level SLAAC availability is centralized on NetworkSegment so a + // future segment flag can be added without auditing DHCP call sites. + let Some(prefix) = segment.slaac_eligible() else { + tracing::debug!( + segment_id = %segment.id, + allocation_strategy = ?segment.config.allocation_strategy, + prefixes = ?segment.prefixes, + "DHCPv6 SLAAC observation skipped because segment is not SLAAC-eligible" + ); + return Ok(()); + }; + + // A stateful/static IPv6 address already owns this interface family. + if db::machine_interface_address::has_address_for_family( + &mut *txn, + interface_id, + IpAddressFamily::Ipv6, + ) + .await? + { + return Ok(()); + } + + // Persist the client-derived address and refresh DNS naming from the new state. + if let Some(address) = slaac_gua_from_eui64(prefix, mac) { + let address = IpAddr::V6(address); + + // TODO: This is a best-effort ownership check, not a complete + // concurrency boundary. Static assignment and preallocation do not yet + // share a segment lock with SLAAC observation, so they can still race + // between this read and insert. A future PR should route DHCP, SLAAC, + // and static address writes through one DB helper that locks the owning + // segment, checks global address ownership, applies the replacement + // policy, and writes the row. + if let Some(existing) = + db::machine_interface_address::find_by_address(&mut *txn, address).await? + { + if existing.id == interface_id { + return Ok(()); + } + + return Err(CarbideError::FailedPrecondition(format!( + "SLAAC address {address} is already allocated to interface {} on segment {}; refusing duplicate observation for interface {interface_id}", + existing.id, existing.name, + ))); + } + + db::machine_interface_address::insert( + &mut *txn, + interface_id, + address, + AllocationType::Slaac, + ) + .await?; + db::machine_interface::sync_hostname_after_address_assignment( + txn, + interface_id, + segment.config.subdomain_id, + ) + .await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use carbide_test_support::value_scenarios; + + use super::*; + + // SLAAC EUI-64: a valid /64 receives the modified EUI-64 identifier, while + // non-/64 and IPv4 prefixes are intentionally ineligible. + #[test] + fn computes_slaac_gua_from_eui64() { + value_scenarios!( + run = |(prefix, mac): (&str, &str)| { + let prefix = IpNetwork::from_str(prefix).unwrap(); + let mac = MacAddress::from_str(mac).unwrap(); + slaac_gua_from_eui64(&prefix, &mac) + }; + "ipv6 /64" { + ("2001:db8::/64", "02:00:00:00:00:01") => Some(Ipv6Addr::from_str("2001:db8::ff:fe00:1").unwrap()), + } + + "ineligible prefixes" { + ("2001:db8::/80", "02:00:00:00:00:01") => None, + ("192.0.2.0/24", "02:00:00:00:00:01") => None, + } + ); + } +} diff --git a/crates/api-core/src/handlers/machine_interface_address.rs b/crates/api-core/src/handlers/machine_interface_address.rs index 895077c9b3..2f197ee187 100644 --- a/crates/api-core/src/handlers/machine_interface_address.rs +++ b/crates/api-core/src/handlers/machine_interface_address.rs @@ -31,7 +31,7 @@ async fn resolve_segment_for_static_ip( txn: &mut sqlx::PgConnection, ip: std::net::IpAddr, ) -> Result { - match db::network_segment::for_relay(txn, ip).await? { + match db::network_segment::for_prefix_containing_address(txn, ip).await? { Some(seg) => Ok(seg), None => Ok(db::network_segment::static_assignments(txn).await?), } diff --git a/crates/api-core/src/tests/machine_dhcp.rs b/crates/api-core/src/tests/machine_dhcp.rs index 16889a9457..32696341a4 100644 --- a/crates/api-core/src/tests/machine_dhcp.rs +++ b/crates/api-core/src/tests/machine_dhcp.rs @@ -15,11 +15,12 @@ * limitations under the License. */ -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr}; use std::str::FromStr; use carbide_network::ip::IpAddressFamily; use carbide_uuid::machine::MachineInterfaceId; +use carbide_uuid::network::NetworkSegmentId; use common::api_fixtures::network_segment::{ FIXTURE_ADMIN_NETWORK_SEGMENT_GATEWAY, FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY, create_host_inband_network_segment, create_network_segment, @@ -27,23 +28,201 @@ use common::api_fixtures::network_segment::{ use common::api_fixtures::{ FIXTURE_DHCP_RELAY_ADDRESS, TestEnv, TestEnvOverrides, create_managed_host, create_managed_host_multi_dpu, create_managed_host_with_config, create_test_env, - create_test_env_with_overrides, dpu, get_config, + create_test_env_with_host_inband, create_test_env_with_overrides, dpu, get_config, + site_explorer, }; use db::{self, ObjectColumnFilter, dhcp_entry}; use ipnetwork::IpNetwork; use itertools::Itertools; use mac_address::MacAddress; +use model::allocation_type::AllocationType; use model::machine_interface::InterfaceType; use model::network_segment::NetworkSegmentType; use model::test_support::ManagedHostConfig; -use rpc::forge::ManagedHostNetworkConfigRequest; use rpc::forge::forge_server::Forge; +use rpc::forge::{ExpireDhcpLeaseRequest, ManagedHostNetworkConfigRequest}; use crate::DatabaseError; -use crate::test_support::fixture_config::ManagedHostConfigExt as _; +use crate::test_support::fixture_config::{FixtureDefault as _, ManagedHostConfigExt as _}; use crate::tests::common; use crate::tests::common::rpc_builder::DhcpDiscovery; +const RPC_ADDRESS_FAMILY_V4: i32 = rpc::forge::AddressFamily::V4 as i32; +const RPC_ADDRESS_FAMILY_V6: i32 = rpc::forge::AddressFamily::V6 as i32; +const RPC_MESSAGE_KIND_V4_DISCOVER: i32 = rpc::forge::MessageKind::V4Discover as i32; +const RPC_MESSAGE_KIND_V6_SOLICIT: i32 = rpc::forge::MessageKind::V6Solicit as i32; +const RPC_MESSAGE_KIND_V6_INFO_REQUEST: i32 = rpc::forge::MessageKind::V6InfoRequest as i32; + +/// Build a DHCPv6 discovery request with explicit protocol fields. +fn dhcpv6_discovery( + mac_address: MacAddress, + relay_address: &str, + message_kind: i32, +) -> tonic::Request { + DhcpDiscovery::builder(mac_address, relay_address) + .address_family(RPC_ADDRESS_FAMILY_V6) + .message_kind(message_kind) + .duid(vec![0x01]) + .tonic_request() +} + +fn dhcpv6_discovery_with_desired_address( + mac_address: MacAddress, + relay_address: &str, + message_kind: i32, + desired_address: IpAddr, +) -> tonic::Request { + let mut request = dhcpv6_discovery(mac_address, relay_address, message_kind); + request.get_mut().desired_address = Some(desired_address.to_string()); + request +} + +fn expected_slaac_address(prefix: Ipv6Addr, mac: MacAddress) -> IpAddr { + let mac = mac.bytes(); + let mut octets = prefix.octets(); + octets[8] = mac[0] ^ 0x02; + octets[9] = mac[1]; + octets[10] = mac[2]; + octets[11] = 0xff; + octets[12] = 0xfe; + octets[13] = mac[3]; + octets[14] = mac[4]; + octets[15] = mac[5]; + IpAddr::V6(Ipv6Addr::from(octets)) +} + +/// Add a v6 prefix to an existing test segment, optionally with a DHCPv6 link-address. +async fn add_ipv6_prefix( + pool: &sqlx::PgPool, + segment_id: NetworkSegmentId, + prefix: &str, + dhcpv6_link_address: Option<&str>, +) -> Result<(), Box> { + add_ipv6_prefix_with_num_reserved(pool, segment_id, prefix, dhcpv6_link_address, 0).await +} + +async fn add_ipv6_prefix_with_num_reserved( + pool: &sqlx::PgPool, + segment_id: NetworkSegmentId, + prefix: &str, + dhcpv6_link_address: Option<&str>, + num_reserved: i32, +) -> Result<(), Box> { + let link_address: Option = dhcpv6_link_address.map(str::parse).transpose()?; + let mut txn = pool.begin().await?; + sqlx::query( + "INSERT INTO network_prefixes (segment_id, prefix, dhcpv6_link_address, num_reserved) + VALUES ($1, $2::cidr, $3::inet, $4)", + ) + .bind(segment_id) + .bind(prefix) + .bind(link_address) + .bind(num_reserved) + .execute(&mut *txn) + .await?; + txn.commit().await?; + Ok(()) +} + +async fn set_dhcpv6_link_address_on_ipv4_prefix( + pool: &sqlx::PgPool, + segment_id: NetworkSegmentId, + dhcpv6_link_address: &str, +) -> Result<(), Box> { + let link_address: IpAddr = dhcpv6_link_address.parse()?; + let mut txn = pool.begin().await?; + sqlx::query( + "UPDATE network_prefixes + SET dhcpv6_link_address = $2::inet + WHERE segment_id = $1 AND family(prefix) = 4", + ) + .bind(segment_id) + .bind(link_address) + .execute(&mut *txn) + .await?; + txn.commit().await?; + Ok(()) +} + +/// Set a test segment to reserved-only allocation. +async fn set_segment_reserved( + pool: &sqlx::PgPool, + segment_id: NetworkSegmentId, +) -> Result<(), Box> { + let mut txn = pool.begin().await?; + sqlx::query("UPDATE network_segments SET allocation_strategy = 'reserved' WHERE id = $1") + .bind(segment_id) + .execute(&mut *txn) + .await?; + txn.commit().await?; + Ok(()) +} + +async fn create_admin_network_segment_with_id( + env: &TestEnv, + id: NetworkSegmentId, + name: &str, + prefix: &str, + gateway: &str, +) -> Result { + let response = env + .api + .create_network_segment(tonic::Request::new( + rpc::forge::NetworkSegmentCreationRequest { + id: Some(id), + mtu: Some(1500), + name: name.to_string(), + prefixes: vec![rpc::forge::NetworkPrefix { + id: None, + prefix: prefix.to_string(), + gateway: Some(gateway.to_string()), + reserve_first: 3, + free_ip_count: 0, + svi_ip: None, + }], + subdomain_id: Some(env.domain.into()), + vpc_id: None, + segment_type: rpc::forge::NetworkSegmentType::Admin as i32, + }, + )) + .await? + .into_inner(); + + Ok(response.id.expect("created segment should return its id")) +} + +/// Create a test environment with DHCP lease expiry handling enabled. +async fn create_test_env_with_dhcp_expiry(pool: sqlx::PgPool) -> TestEnv { + create_test_env_with_overrides( + pool, + TestEnvOverrides { + dhcp_lease_expiry_handling: Some(true), + ..Default::default() + }, + ) + .await +} + +async fn interface_addresses_for_mac( + pool: &sqlx::PgPool, + mac: MacAddress, +) -> Result< + ( + MachineInterfaceId, + Vec, + ), + Box, +> { + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + let interface_id = interfaces[0].id; + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + txn.rollback().await?; + Ok((interface_id, addresses)) +} + #[crate::sqlx_test] async fn test_machine_dhcp(pool: sqlx::PgPool) -> Result<(), Box> { let env = create_test_env(pool).await; @@ -728,6 +907,1863 @@ async fn test_dhcp_record_address_family( Ok(()) } +// DHCPv4 and DHCPv6 for one physical NIC should merge into one interface row, +// while each response is routed to the requested address family. +#[crate::sqlx_test] +async fn test_dhcp_v6_solicit_merges_with_ipv4_interface( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:01").unwrap(); + + // Make the segment dual-stack before first contact; legacy v4 must not + // preallocate a v6 DHCP row. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:2::/64", None).await?; + + // First create the legacy DHCPv4 interface and address. + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request()) + .await? + .into_inner(); + assert!(v4_response.address.parse::()?.is_ipv4()); + + // Read persisted state after v4 first contact; only the v4 DHCP row should exist. + let (interface_id, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(v4_response.machine_interface_id, Some(interface_id)); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert!(addresses[0].address.is_ipv4()); + + // Request DHCPv6 later for the same MAC; it should add only the v6 family. + let v6_response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:2::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert!(v6_response.address.parse::()?.is_ipv6()); + assert_eq!( + v4_response.machine_interface_id, + v6_response.machine_interface_id + ); + + // Verify persistence through a fresh DB read, not only the response values. + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 2); + assert!(addresses.iter().any(|address| { + address.allocation_type == AllocationType::Dhcp && address.address.is_ipv4() + })); + assert!(addresses.iter().any(|address| { + address.allocation_type == AllocationType::Dhcp && address.address.is_ipv6() + })); + + Ok(()) +} + +// DHCPv6 information-request observes a SLAAC address once and returns only +// site options, so it must not allocate a DHCP lease. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_records_single_slaac_observation( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:02").unwrap(); + + // Seed exactly one IPv6 /64 on the admin segment and send an information-request. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:3::/64", None).await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:3::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + assert!(response.last_invalidation_time.is_some()); + + // Read back the persisted address and confirm it is the EUI-64 SLAAC GUA. + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!( + response.fqdn, + format!("{}.dwrt1.com", interfaces[0].hostname) + ); + let interface_id = interfaces[0].id; + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + IpAddr::V6(Ipv6Addr::from_str("2001:db8:3::ff:fe00:2").unwrap()) + ); + txn.rollback().await?; + + // Repeat the same observation; the family pre-check makes it idempotent. + env.api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:3::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await?; + let mut txn = pool.begin().await?; + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + txn.rollback().await?; + + Ok(()) +} + +// SLAAC observation is best-effort guarded against address ownership conflicts: +// if the computed EUI-64 address is already held by another interface, reject +// instead of creating a duplicate machine_interface_addresses row. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_rejects_slaac_address_owned_by_other_interface( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let owner_mac = MacAddress::from_str("02:00:00:00:00:2a").unwrap(); + let requester_mac = MacAddress::from_str("02:00:00:00:00:2b").unwrap(); + + // Give the segment a SLAAC-eligible prefix and create an unrelated owner. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:87::/64", None).await?; + let owner_response = env + .api + .discover_dhcp( + DhcpDiscovery::builder(owner_mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request(), + ) + .await? + .into_inner(); + let owner_interface_id = owner_response + .machine_interface_id + .expect("owner interface should exist"); + + // Seed the requester's computed SLAAC address on the owner interface. + let duplicate_slaac = expected_slaac_address("2001:db8:87::".parse()?, requester_mac); + let mut txn = pool.begin().await?; + db::machine_interface_address::insert( + &mut txn, + owner_interface_id, + duplicate_slaac, + AllocationType::Static, + ) + .await?; + txn.commit().await?; + + // The request must reject instead of adding duplicate address ownership. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + requester_mac, + "2001:db8:87::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("duplicate SLAAC ownership should reject"); + assert_eq!(status.code(), tonic::Code::FailedPrecondition); + assert!(status.message().contains("already allocated to interface")); + + let mut txn = pool.begin().await?; + let requester_interfaces = + db::machine_interface::find_by_mac_address(&mut *txn, requester_mac).await?; + assert!(requester_interfaces.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// DHCPv6 information-request on a v6-enabled but SLAAC-ineligible prefix +// returns options only and must not persist an IPv6 address. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_with_non_64_prefix_returns_options_only( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:0d").unwrap(); + + // Seed a single IPv6 prefix that enables v6 but is not SLAAC-eligible. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:f::/80", None).await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:f::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert_eq!(response.segment_id, Some(env.admin_segment())); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + assert!(response.last_invalidation_time.is_some()); + + // Verify the observation persisted the interface identity, but no address. + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!(response.machine_interface_id, Some(interfaces[0].id)); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interfaces[0].id).await?; + assert!(addresses.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// DHCPv6 SLAAC observation after a v4 lease expiration must restore the +// segment domain so the options-only DHCP record remains visible. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_restores_domain_after_v4_expiration( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_dhcp_expiry(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:0c").unwrap(); + + // Make the segment dual-stack and create the initial IPv4 DHCP lease. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:e::/64", None).await?; + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response + .machine_interface_id + .expect("DHCP response should include an interface id"); + + // Expire the only address; the deletion path should park the row outside DNS. + env.api + .expire_dhcp_lease(tonic::Request::new(ExpireDhcpLeaseRequest { + ip_address: v4_response.address, + mac_address: Some(mac.to_string()), + })) + .await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert!(interface.addresses.is_empty()); + assert!(interface.domain_id.is_none()); + txn.rollback().await?; + + // Observe SLAAC on the same row; the options-only response should keep metadata. + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:e::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.machine_interface_id, Some(interface_id)); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert!(response.last_invalidation_time.is_some()); + + // Verify persistence after a fresh DB read, not just the response. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert_eq!(interface.domain_id, Some(env.domain.into())); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + expected_slaac_address("2001:db8:e::".parse()?, mac) + ); + txn.rollback().await?; + + Ok(()) +} + +// DHCPv6 information-request on a SLAAC-ineligible segment still returns FQDN +// options after a prior lease expiration, without rejoining DNS. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_with_non_64_prefix_keeps_fqdn_after_expiration( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_dhcp_expiry(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:0e").unwrap(); + + // Make the segment v6-enabled but SLAAC-ineligible, then create a v4 lease. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:10::/80", None).await?; + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response + .machine_interface_id + .expect("DHCP response should include an interface id"); + + // Expire the only address so the persisted row becomes DNS-silent. + env.api + .expire_dhcp_lease(tonic::Request::new(ExpireDhcpLeaseRequest { + ip_address: v4_response.address, + mac_address: Some(mac.to_string()), + })) + .await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert!(interface.addresses.is_empty()); + assert!(interface.domain_id.is_none()); + txn.rollback().await?; + + // Request v6 options; no SLAAC address is eligible, but FQDN should survive. + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:10::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.machine_interface_id, Some(interface_id)); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + assert!(response.last_invalidation_time.is_some()); + + // Verify no address was persisted and FQDN came from the segment domain. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert_eq!(interface.domain_id, None); + assert_eq!(response.fqdn, format!("{}.dwrt1.com", interface.hostname)); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert!(addresses.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// A DHCPv6 relay link-address can identify the segment even when it sits +// outside the segment's IPv6 prefix. +#[crate::sqlx_test] +async fn test_dhcp_v6_link_address_matches_segment_outside_prefix( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:03").unwrap(); + + // Seed a DHCPv6 link-address outside the prefix and use it as the relay. + add_ipv6_prefix( + &pool, + env.admin_segment(), + "2001:db8:5::/64", + Some("2001:db8:ffff::1"), + ) + .await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:ffff::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert_eq!(response.segment_id.unwrap(), env.admin_segment()); + assert!(response.address.parse::()?.is_ipv6()); + + // Verify a nearby address with no equality match resolves to no segment. + let mut txn = pool.begin().await?; + let missing = db::network_segment::for_relay(&mut txn, "2001:db8:ffff::2".parse()?).await?; + assert!(missing.is_none()); + txn.rollback().await?; + + Ok(()) +} + +// An exact DHCPv6 link-address match is the relay's authoritative segment, +// even when the link-address also falls inside another segment's prefix. +#[crate::sqlx_test] +async fn test_dhcp_v6_link_address_exact_match_precedes_prefix_candidate( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let prefix_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000201")?; + let exact_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000202")?; + let relay = "2001:db8:b::1"; + let mac = MacAddress::from_str("02:00:00:00:00:22").unwrap(); + + // The lower UUID segment owns the relay by prefix containment. + create_admin_network_segment_with_id( + &env, + prefix_segment, + "ADMIN_V6_PREFIX_CONTAINS_LINK", + "192.0.62.0/24", + "192.0.62.1", + ) + .await?; + add_ipv6_prefix(&pool, prefix_segment, "2001:db8:b::/64", None).await?; + + // The higher UUID segment owns the relay by exact DHCPv6 link-address. + create_admin_network_segment_with_id( + &env, + exact_segment, + "ADMIN_V6_EXACT_LINK", + "192.0.63.0/24", + "192.0.63.1", + ) + .await?; + add_ipv6_prefix(&pool, exact_segment, "2001:db8:a::/64", Some(relay)).await?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery(mac, relay, RPC_MESSAGE_KIND_V6_SOLICIT)) + .await? + .into_inner(); + assert_eq!(response.segment_id, Some(exact_segment)); + assert!(response.address.parse::()?.is_ipv6()); + + Ok(()) +} + +// Stateful DHCPv6 must not let a reserved prefix fallback veto a dynamic exact +// link-address candidate. IPv4 keeps the old all-candidate reserved veto. +#[crate::sqlx_test] +async fn test_dhcp_v6_solicit_exact_link_precedes_reserved_prefix_candidate( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let reserved_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000205")?; + let exact_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000206")?; + let relay = "2001:db8:82::1"; + let mac = MacAddress::from_str("02:00:00:00:00:25").unwrap(); + + // A reserved prefix fallback contains the relay. + create_admin_network_segment_with_id( + &env, + reserved_segment, + "ADMIN_V6_RESERVED_SOLICIT_PREFIX", + "192.0.82.0/24", + "192.0.82.1", + ) + .await?; + add_ipv6_prefix(&pool, reserved_segment, "2001:db8:82::/64", None).await?; + set_segment_reserved(&pool, reserved_segment).await?; + + // A dynamic exact link-address candidate is authoritative. + create_admin_network_segment_with_id( + &env, + exact_segment, + "ADMIN_V6_EXACT_SOLICIT_DYNAMIC", + "192.0.83.0/24", + "192.0.83.1", + ) + .await?; + add_ipv6_prefix(&pool, exact_segment, "2001:db8:83::/64", Some(relay)).await?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery(mac, relay, RPC_MESSAGE_KIND_V6_SOLICIT)) + .await? + .into_inner(); + let response_address: IpAddr = response.address.parse()?; + assert_eq!(response.segment_id, Some(exact_segment)); + assert!(response_address.is_ipv6()); + + // Verify the dynamic allocation was persisted only on the exact segment. + let (interface_id, addresses) = interface_addresses_for_mac(&pool, mac).await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + txn.rollback().await?; + assert_eq!(interface.segment_id, exact_segment); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(addresses[0].address, response_address); + + Ok(()) +} + +// Exact DHCPv6 link-address routing is authoritative even when expected host +// NIC metadata declares a different segment type. +#[crate::sqlx_test] +async fn test_dhcp_v6_solicit_exact_link_precedes_expected_host_nic_type_filter( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let relay = "2001:db8:90::1"; + let bmc_mac: MacAddress = "02:00:00:00:00:27".parse().unwrap(); + let host_mac: MacAddress = "02:00:00:00:00:28".parse().unwrap(); + + // Create a declared-type prefix fallback and a different-type exact match. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:90::/64", None).await?; + let exact_segment = create_network_segment( + &env.api, + "UNDERLAY_V6_EXACT_BEATS_EXPECTED_ADMIN", + "192.0.90.0/24", + "192.0.90.1", + rpc::forge::NetworkSegmentType::Underlay, + None, + true, + ) + .await; + add_ipv6_prefix(&pool, exact_segment, "2001:db8:91::/64", Some(relay)).await?; + + // Declare the host NIC as Admin; the exact link-address must still win. + env.api + .add_expected_machine(tonic::Request::new(rpc::forge::ExpectedMachine { + id: None, + bmc_mac_address: bmc_mac.to_string(), + bmc_username: "ADMIN".into(), + bmc_password: "PASS".into(), + chassis_serial_number: "EM-DHCPV6-EXACT-TYPE-001".into(), + host_nics: vec![rpc::forge::ExpectedHostNic { + mac_address: host_mac.to_string(), + network_segment_type: Some(rpc::forge::NetworkSegmentType::Admin as i32), + primary: Some(true), + ..Default::default() + }], + ..Default::default() + })) + .await?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + host_mac, + relay, + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + let response_address: IpAddr = response.address.parse()?; + assert_eq!(response.segment_id, Some(exact_segment)); + assert!(response_address.is_ipv6()); + + // Verify persistence came from the exact segment, not the declared-type fallback. + let (interface_id, addresses) = interface_addresses_for_mac(&pool, host_mac).await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + txn.rollback().await?; + assert_eq!(interface.segment_id, exact_segment); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(addresses[0].address, response_address); + + Ok(()) +} + +// INFORMATION-REQUEST uses the same exact-link authority before expected NIC +// type narrowing, so SLAAC observation lands on the exact segment. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_exact_link_precedes_expected_host_nic_type_filter( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let relay = "2001:db8:92::1"; + let bmc_mac: MacAddress = "02:00:00:00:00:29".parse().unwrap(); + let host_mac: MacAddress = "02:00:00:00:00:2a".parse().unwrap(); + + // Create a declared-type prefix fallback and a different-type exact match. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:92::/64", None).await?; + let exact_segment = create_network_segment( + &env.api, + "UNDERLAY_V6_INFO_EXACT_BEATS_EXPECTED_ADMIN", + "192.0.92.0/24", + "192.0.92.1", + rpc::forge::NetworkSegmentType::Underlay, + None, + true, + ) + .await; + add_ipv6_prefix(&pool, exact_segment, "2001:db8:93::/64", Some(relay)).await?; + + // Declare the host NIC as Admin; the exact link-address must still win. + env.api + .add_expected_machine(tonic::Request::new(rpc::forge::ExpectedMachine { + id: None, + bmc_mac_address: bmc_mac.to_string(), + bmc_username: "ADMIN".into(), + bmc_password: "PASS".into(), + chassis_serial_number: "EM-DHCPV6-INFO-EXACT-TYPE-001".into(), + host_nics: vec![rpc::forge::ExpectedHostNic { + mac_address: host_mac.to_string(), + network_segment_type: Some(rpc::forge::NetworkSegmentType::Admin as i32), + primary: Some(true), + ..Default::default() + }], + ..Default::default() + })) + .await?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + host_mac, + relay, + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert_eq!(response.segment_id, Some(exact_segment)); + + // Verify SLAAC observation used the exact segment prefix. + let (interface_id, addresses) = interface_addresses_for_mac(&pool, host_mac).await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + txn.rollback().await?; + assert_eq!(interface.segment_id, exact_segment); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + expected_slaac_address("2001:db8:93::".parse()?, host_mac) + ); + + Ok(()) +} + +#[crate::sqlx_test] +async fn test_dhcp_rejects_explicit_protocol_family_mismatched_with_relay_context( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let v6_request_mac = MacAddress::from_str("02:00:00:00:00:23").unwrap(); + let v4_request_mac = MacAddress::from_str("02:00:00:00:00:24").unwrap(); + let v6_link_address = "2001:db8:ffff:18::1"; + + // Make the admin segment dual-stack and addressable by DHCPv6 link-address + // so the old path could resolve it if protocol parsing allowed the mismatch. + add_ipv6_prefix( + &pool, + env.admin_segment(), + "2001:db8:18::/64", + Some(v6_link_address), + ) + .await?; + + // Explicit IPv6 protocol fields cannot use an IPv4 relay context. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + v6_request_mac, + FIXTURE_DHCP_RELAY_ADDRESS, + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("IPv6 protocol fields with an IPv4 relay should reject"); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!( + status + .message() + .contains("address_family must match relay/link-address family") + ); + + // Explicit IPv4 protocol fields cannot use an IPv6 link-address context. + let mut v4_request = DhcpDiscovery::builder(v4_request_mac, FIXTURE_DHCP_RELAY_ADDRESS) + .address_family(RPC_ADDRESS_FAMILY_V4) + .message_kind(RPC_MESSAGE_KIND_V4_DISCOVER) + .tonic_request(); + v4_request.get_mut().link_address = Some(v6_link_address.to_string()); + let status = env + .api + .discover_dhcp(v4_request) + .await + .expect_err("IPv4 protocol fields with an IPv6 link-address should reject"); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!( + status + .message() + .contains("address_family must match relay/link-address family") + ); + + // Both rejects happen before interface/address persistence. + let mut txn = pool.begin().await?; + for mac in [v6_request_mac, v4_request_mac] { + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert!(interfaces.is_empty()); + } + txn.rollback().await?; + + Ok(()) +} + +// Existing-machine detection uses DHCP relay semantics, including an +// off-prefix DHCPv6 link-address. +#[crate::sqlx_test] +async fn test_dhcp_v6_find_existing_machine_uses_link_address( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let host = create_managed_host(&env).await; + let (host_mac, gateway) = host_interface_and_gateway(&env, host.host().id).await?; + let host_segment = { + let mut txn = pool.begin().await?; + let segment = db::network_segment::for_relay(&mut txn, gateway) + .await? + .expect("host segment should resolve by its IPv4 gateway"); + txn.rollback().await?; + segment.id + }; + + // The link-address is deliberately outside the segment prefix; relay lookup + // should still find the known host machine. + let link_address: IpAddr = "2001:db8:ffff:16::1".parse()?; + add_ipv6_prefix( + &pool, + host_segment, + "2001:db8:16::/64", + Some("2001:db8:ffff:16::1"), + ) + .await?; + let mut txn = pool.begin().await?; + let machine_id = db::machine::find_existing_machine(&mut txn, host_mac, link_address).await?; + assert_eq!(machine_id, Some(host.host().id)); + txn.rollback().await?; + + Ok(()) +} + +// Exact DHCPv6 link-address ownership must beat a known machine on a prefix +// fallback segment; otherwise known-machine lookup skips segment reconciliation. +#[crate::sqlx_test] +async fn test_dhcp_v6_exact_link_rejects_known_machine_on_prefix_fallback( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let host = create_managed_host(&env).await; + let (host_mac, gateway) = host_interface_and_gateway(&env, host.host().id).await?; + let exact_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000204")?; + let relay = "2001:db8:80::1"; + + // Put the known host on a segment whose prefix contains the relay. + let host_segment = { + let mut txn = pool.begin().await?; + let segment = db::network_segment::for_relay(&mut txn, gateway) + .await? + .expect("host segment should resolve by gateway"); + txn.rollback().await?; + segment.id + }; + add_ipv6_prefix(&pool, host_segment, "2001:db8:80::/64", None).await?; + + // Put the exact DHCPv6 link-address on a different segment. + create_admin_network_segment_with_id( + &env, + exact_segment, + "ADMIN_V6_EXACT_REJECTS_KNOWN_PREFIX", + "192.0.80.0/24", + "192.0.80.1", + ) + .await?; + add_ipv6_prefix(&pool, exact_segment, "2001:db8:81::/64", Some(relay)).await?; + + // Known-machine lookup must not accept the prefix fallback when exact exists. + let mut txn = pool.begin().await?; + let machine_id = db::machine::find_existing_machine(&mut txn, host_mac, relay.parse()?).await?; + assert!(machine_id.is_none()); + txn.rollback().await?; + + // The DHCP path should hit the existing-MAC segment guard and reject. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + host_mac, + relay, + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await + .expect_err("known MAC on prefix fallback should reject exact-link DHCP"); + assert_eq!(status.code(), tonic::Code::Internal); + assert!( + status + .message() + .contains("Network segment mismatch for existing MAC address") + ); + + Ok(()) +} + +// If a client was first observed through SLAAC and later asks for stateful +// DHCPv6, the stateful allocation replaces the observed SLAAC row. +#[crate::sqlx_test] +async fn test_dhcp_v6_stateful_replaces_prior_slaac_observation( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:04").unwrap(); + + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:6::/64", None).await?; + env.api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:6::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await?; + let (interface_id, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:6::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert_eq!(response.machine_interface_id, Some(interface_id)); + let response_address: IpAddr = response.address.parse()?; + + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(addresses[0].address, response_address); + + Ok(()) +} + +// Fixed IPv6 reservations are materialized before SLAAC observation, so an +// information-request does not create a transient SLAAC row for a static client. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_materializes_fixed_reservation( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let bmc_mac = MacAddress::from_str("02:00:00:00:00:09").unwrap(); + let mac = MacAddress::from_str("02:00:00:00:00:0a").unwrap(); + let fixed_ip: IpAddr = "2001:db8:a::55".parse()?; + + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:a::/64", None).await?; + env.api + .add_expected_machine(tonic::Request::new(rpc::forge::ExpectedMachine { + id: None, + bmc_mac_address: bmc_mac.to_string(), + bmc_username: "ADMIN".into(), + bmc_password: "PASS".into(), + chassis_serial_number: "EM-DHCPV6-FIXED-001".into(), + host_nics: vec![rpc::forge::ExpectedHostNic { + network_segment_type: None, + mac_address: mac.to_string(), + nic_type: Some("onboard".into()), + fixed_ip: Some(fixed_ip.to_string()), + fixed_mask: None, + fixed_gateway: None, + primary: None, + }], + ..Default::default() + })) + .await?; + + let info_response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:a::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(info_response.address, ""); + assert_eq!(info_response.prefix, ""); + + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Static); + assert_eq!(addresses[0].address, fixed_ip); + + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:a::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert_eq!(response.address, fixed_ip.to_string()); + + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Static); + assert_eq!(addresses[0].address, fixed_ip); + + Ok(()) +} + +// Fixed IPv6 reservations assigned to an existing addressless interface must +// restore the segment domain so the DHCP projection can return the lease. +#[crate::sqlx_test] +async fn test_dhcp_v6_fixed_reservation_restores_domain_after_v4_expiration( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_dhcp_expiry(pool.clone()).await; + let bmc_mac = MacAddress::from_str("02:00:00:00:00:15").unwrap(); + let mac = MacAddress::from_str("02:00:00:00:00:16").unwrap(); + let fixed_ip: IpAddr = "2001:db8:15::55".parse()?; + + // Create then expire a v4 lease so the existing interface has no domain. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:15::/64", None).await?; + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response.machine_interface_id.unwrap(); + env.api + .expire_dhcp_lease(tonic::Request::new(ExpireDhcpLeaseRequest { + ip_address: v4_response.address, + mac_address: Some(mac.to_string()), + })) + .await?; + + // Configure the fixed IPv6 reservation after the row is addressless. + env.api + .add_expected_machine(tonic::Request::new(rpc::forge::ExpectedMachine { + id: None, + bmc_mac_address: bmc_mac.to_string(), + bmc_username: "ADMIN".into(), + bmc_password: "PASS".into(), + chassis_serial_number: "EM-DHCPV6-FIXED-EXPIRED-001".into(), + host_nics: vec![rpc::forge::ExpectedHostNic { + network_segment_type: None, + mac_address: mac.to_string(), + nic_type: Some("onboard".into()), + fixed_ip: Some(fixed_ip.to_string()), + fixed_mask: None, + fixed_gateway: None, + primary: None, + }], + ..Default::default() + })) + .await?; + + // Stateful DHCPv6 should materialize and serve the fixed reservation. + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:15::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert_eq!(response.machine_interface_id, Some(interface_id)); + assert_eq!(response.address, fixed_ip.to_string()); + + // Verify the address assignment restored domain membership in storage. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert_eq!(interface.domain_id, Some(env.domain.into())); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Static); + assert_eq!(addresses[0].address, fixed_ip); + txn.rollback().await?; + + Ok(()) +} + +// Fixed IPv6 reservations on reserved, SLAAC-ineligible segments still return +// options-only metadata and persist the configured static address. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_materializes_fixed_reservation_on_reserved_non_64( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let bmc_mac = MacAddress::from_str("02:00:00:00:00:0f").unwrap(); + let mac = MacAddress::from_str("02:00:00:00:00:10").unwrap(); + let fixed_ip: IpAddr = "2001:db8:11::55".parse()?; + + // Make the admin segment IPv6-enabled but SLAAC-ineligible and static-only. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:11::/80", None).await?; + set_segment_reserved(&pool, env.admin_segment()).await?; + + // Configure an expected-host reservation that should satisfy the reserved segment. + env.api + .add_expected_machine(tonic::Request::new(rpc::forge::ExpectedMachine { + id: None, + bmc_mac_address: bmc_mac.to_string(), + bmc_username: "ADMIN".into(), + bmc_password: "PASS".into(), + chassis_serial_number: "EM-DHCPV6-FIXED-RESERVED-001".into(), + host_nics: vec![rpc::forge::ExpectedHostNic { + network_segment_type: None, + mac_address: mac.to_string(), + nic_type: Some("onboard".into()), + fixed_ip: Some(fixed_ip.to_string()), + fixed_mask: None, + fixed_gateway: None, + primary: None, + }], + ..Default::default() + })) + .await?; + + // INFORMATION-REQUEST must return options only, not allocate dynamically. + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:11::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert_eq!(response.segment_id, Some(env.admin_segment())); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + + // Verify the static reservation was persisted and response metadata came + // from the same interface without writing a SLAAC row. + let (interface_id, addresses) = interface_addresses_for_mac(&pool, mac).await?; + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + txn.rollback().await?; + assert_eq!(response.machine_interface_id, Some(interface_id)); + assert_eq!(response.machine_id, interface.machine_id); + assert_eq!(response.fqdn, format!("{}.dwrt1.com", interface.hostname)); + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Static); + assert_eq!(addresses[0].address, fixed_ip); + + Ok(()) +} + +// Reserved IPv6-enabled segments still deliver DHCPv6 options, but they must +// not create an observed interface row or SLAAC address without a reservation. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_reserved_segment_returns_options_only_without_observation( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:11").unwrap(); + + // Make the admin segment v6-enabled and reserved-only before first contact. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:12::/64", None).await?; + set_segment_reserved(&pool, env.admin_segment()).await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:12::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert!(response.gateway.is_none()); + assert_eq!(response.mac_address, mac.to_string()); + assert_eq!(response.machine_id, None); + assert_eq!(response.machine_interface_id, None); + assert_eq!(response.segment_id, Some(env.admin_segment())); + assert_eq!(response.subdomain_id, Some(env.domain.into())); + assert!(response.last_invalidation_time.is_some()); + + // Verify the options request did not persist an observed interface. + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert!(interfaces.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// Known same-segment reserved INFORMATION-REQUESTs still return options, but +// must pass through common bookkeeping such as last_dhcp updates. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_reserved_known_interface_updates_last_dhcp( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:26").unwrap(); + + // Create a known same-segment interface with an IPv4 lease. + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, FIXTURE_DHCP_RELAY_ADDRESS).tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response + .machine_interface_id + .expect("DHCP should create interface"); + + // Make the same segment IPv6-enabled and reserved-only, then age last_dhcp. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:84::/64", None).await?; + set_segment_reserved(&pool, env.admin_segment()).await?; + let old_last_dhcp = chrono::Utc::now() - chrono::Duration::days(1); + let mut txn = pool.begin().await?; + db::machine_interface::update_last_dhcp(&mut txn, interface_id, Some(old_last_dhcp)).await?; + txn.commit().await?; + + // The response is options-only, but the known interface path runs bookkeeping. + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:84::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert_eq!(response.machine_interface_id, Some(interface_id)); + + // Verify last_dhcp advanced and no IPv6 address was persisted. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert!(interface.last_dhcp.expect("last_dhcp should be set") > old_last_dhcp); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert!(addresses[0].address.is_ipv4()); + txn.rollback().await?; + + Ok(()) +} + +// Known reserved INFORMATION-REQUESTs must not bypass dormant interface checks. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_reserved_dormant_admin_interface_rejects( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + + // Create a multi-DPU host and find a dormant DPU-backed admin interface. + let mh = create_managed_host_multi_dpu(&env, 2).await; + let mut txn = pool.begin().await?; + let mut interface_map = db::machine_interface::find_by_machine_ids(&mut txn, &[mh.id]).await?; + let dormant_interface = interface_map + .remove(&mh.id) + .expect("multi-DPU host has machine interfaces") + .into_iter() + .find(|interface| { + interface.network_segment_type == Some(NetworkSegmentType::Admin) + && interface.attached_dpu_machine_id.is_some() + && !interface.primary_interface + }) + .expect("multi-DPU host has a dormant admin interface"); + txn.rollback().await?; + + // Make the dormant interface's segment IPv6-enabled and reserved-only. + add_ipv6_prefix( + &pool, + dormant_interface.segment_id, + "2001:db8:85::/64", + None, + ) + .await?; + set_segment_reserved(&pool, dormant_interface.segment_id).await?; + + // The request must enter the common path and reject as dormant. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + dormant_interface.mac_address, + "2001:db8:85::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("dormant reserved INFO_REQUEST should reject"); + assert_eq!(status.code(), tonic::Code::FailedPrecondition); + assert!( + status + .message() + .contains("dormant non-primary admin interface") + ); + + Ok(()) +} + +// Reserved segments still enforce the global MAC guard before options delivery. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_reserved_segment_rejects_known_interface_on_other_segment( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_dhcp_expiry(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:12").unwrap(); + + // Create then expire a v4 lease on another managed segment. + let other_segment = create_network_segment( + &env.api, + "ADMIN_RESERVED_INFO_SRC", + "192.0.40.0/24", + "192.0.40.1", + rpc::forge::NetworkSegmentType::Admin, + None, + true, + ) + .await; + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, "192.0.40.1").tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response + .machine_interface_id + .expect("DHCP response should include an interface id"); + env.api + .expire_dhcp_lease(tonic::Request::new(ExpireDhcpLeaseRequest { + ip_address: v4_response.address, + mac_address: Some(mac.to_string()), + })) + .await?; + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:13::/64", None).await?; + set_segment_reserved(&pool, env.admin_segment()).await?; + + // Request options on the reserved v6 segment; the wrong-segment MAC must + // reject before options construction. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:13::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("wrong-segment known MAC should reject before options"); + assert_eq!(status.code(), tonic::Code::Internal); + assert!( + status + .message() + .contains("Network segment mismatch for existing MAC address") + ); + + // Verify the known interface stayed on its original segment without an address. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert_eq!(interface.segment_id, other_segment); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert!(addresses.is_empty()); + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + txn.rollback().await?; + + Ok(()) +} + +// Non-reserved information-request enforces the same global MAC guard as +// IPv4/stateful DHCP before options delivery. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_non_reserved_segment_rejects_known_interface_on_other_segment( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:17").unwrap(); + + // Create an addressed v4 identity on another managed segment. + let other_segment = create_network_segment( + &env.api, + "ADMIN_INFO_SRC", + "192.0.41.0/24", + "192.0.41.1", + rpc::forge::NetworkSegmentType::Admin, + None, + true, + ) + .await; + let v4_response = env + .api + .discover_dhcp(DhcpDiscovery::builder(mac, "192.0.41.1").tonic_request()) + .await? + .into_inner(); + let interface_id = v4_response + .machine_interface_id + .expect("DHCP response should include an interface id"); + let v4_address: IpAddr = v4_response.address.parse()?; + + // Request v6 options on the original admin segment; the wrong-segment MAC + // must reject before options construction. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:17::/64", None).await?; + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:17::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("wrong-segment known MAC should reject before options"); + assert_eq!(status.code(), tonic::Code::Internal); + assert!( + status + .message() + .contains("Network segment mismatch for existing MAC address") + ); + + // Verify the known interface stayed on its original segment with its v4 lease. + let mut txn = pool.begin().await?; + let interface = db::machine_interface::find_one(&mut *txn, interface_id).await?; + assert_eq!(interface.segment_id, other_segment); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface_id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].address, v4_address); + + // Verify no second managed interface or SLAAC row was created. + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + txn.rollback().await?; + + Ok(()) +} + +// A stateful DHCPv6 row is authoritative; later information-requests must not +// add a coexisting SLAAC row for the same interface. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_does_not_add_slaac_after_stateful( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:05").unwrap(); + + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:7::/64", None).await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:7::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + let stateful_address: IpAddr = response.address.parse()?; + + env.api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:7::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await?; + + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(addresses[0].address, stateful_address); + + Ok(()) +} + +#[crate::sqlx_test] +async fn test_dhcp_v6_solicit_exact_link_exhaustion_does_not_fallback_to_prefix_candidate( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let relay = "2001:db8:60::abcd"; + let first_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000101")?; + let second_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000102")?; + let first_mac = MacAddress::from_str("02:00:00:00:00:20").unwrap(); + let second_mac = MacAddress::from_str("02:00:00:00:00:21").unwrap(); + + // Create the lower-sorted candidate first and give it a single IPv6 lease + // reachable only through DHCPv6 link-address routing. + create_admin_network_segment_with_id( + &env, + first_segment, + "ADMIN_V6_FIRST_EXHAUSTED", + "192.0.60.0/24", + "192.0.60.1", + ) + .await?; + add_ipv6_prefix_with_num_reserved(&pool, first_segment, "2001:db8:61::/127", Some(relay), 1) + .await?; + let first_response = env + .api + .discover_dhcp(dhcpv6_discovery( + first_mac, + relay, + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + assert_eq!(first_response.segment_id, Some(first_segment)); + + // Add a later candidate whose prefix contains the relay. Because the first + // candidate is an exact DHCPv6 link-address match, prefix fallback is only + // routing context and must not receive allocation after exhaustion. + create_admin_network_segment_with_id( + &env, + second_segment, + "ADMIN_V6_SECOND_AVAILABLE", + "192.0.61.0/24", + "192.0.61.1", + ) + .await?; + add_ipv6_prefix(&pool, second_segment, "2001:db8:60::/64", None).await?; + + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + second_mac, + relay, + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await + .expect_err("exact DHCPv6 link-address exhaustion should not fallback"); + assert_eq!(status.code(), tonic::Code::ResourceExhausted); + + // Verify the rejected request did not persist a fallback-segment interface. + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, second_mac).await?; + txn.rollback().await?; + assert!(interfaces.is_empty()); + + Ok(()) +} + +// DHCPv6 information-request must not persist an address supplied by the +// packet. Relay and desired addresses are ignored for SLAAC observation, so +// the requester receives only options while the computed EUI-64 row is stored. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_ignores_adversarial_ipv6_address( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let victim_mac = MacAddress::from_str("02:00:00:00:00:06").unwrap(); + let attacker_mac = MacAddress::from_str("02:00:00:00:00:07").unwrap(); + + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:8::/64", None).await?; + let victim_response = env + .api + .discover_dhcp(dhcpv6_discovery( + victim_mac, + "2001:db8:8::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await? + .into_inner(); + let victim_address: IpAddr = victim_response.address.parse()?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery_with_desired_address( + attacker_mac, + &victim_address.to_string(), + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + victim_address, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + + // Verify the victim's stateful row was not disturbed. + let (_, victim_addresses) = interface_addresses_for_mac(&pool, victim_mac).await?; + assert_eq!(victim_addresses.len(), 1); + assert_eq!(victim_addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(victim_addresses[0].address, victim_address); + + // Verify the attacker persisted only its server-computed SLAAC address. + let (_, attacker_addresses) = interface_addresses_for_mac(&pool, attacker_mac).await?; + assert_eq!(attacker_addresses.len(), 1); + assert_eq!(attacker_addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + attacker_addresses[0].address, + expected_slaac_address("2001:db8:8::".parse()?, attacker_mac) + ); + + Ok(()) +} + +// DHCPv6 information-request ignores desired_address entirely, so malformed +// allocation hints must not reject options delivery or SLAAC observation. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_ignores_malformed_desired_address( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:13").unwrap(); + + // Send an otherwise valid information-request with an invalid address hint. + add_ipv6_prefix(&pool, env.admin_segment(), "2001:db8:14::/64", None).await?; + let mut request = dhcpv6_discovery(mac, "2001:db8:14::1", RPC_MESSAGE_KIND_V6_INFO_REQUEST); + request.get_mut().desired_address = Some("not-an-ip".to_string()); + let response = env.api.discover_dhcp(request).await?.into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + + // Verify the malformed hint did not suppress the computed SLAAC row. + let (_, addresses) = interface_addresses_for_mac(&pool, mac).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + expected_slaac_address("2001:db8:14::".parse()?, mac) + ); + + Ok(()) +} + +// A relay can identify a segment through dhcpv6_link_address, but DHCPv6 +// cannot be served unless the segment has an IPv6 prefix. +#[crate::sqlx_test] +async fn test_dhcp_v6_request_without_v6_prefix_is_rejected( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool.clone()).await; + let mac = MacAddress::from_str("02:00:00:00:00:08").unwrap(); + + set_dhcpv6_link_address_on_ipv4_prefix(&pool, env.admin_segment(), "2001:db8:9::1").await?; + let fallback_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000109")?; + + // Add a prefix fallback that could serve IPv6. The exact link-address + // segment remains authoritative and must reject instead of falling back. + create_admin_network_segment_with_id( + &env, + fallback_segment, + "ADMIN_V6_PREFIX_FALLBACK_WITH_V6", + "192.0.109.0/24", + "192.0.109.1", + ) + .await?; + add_ipv6_prefix(&pool, fallback_segment, "2001:db8:9::/64", None).await?; + + // Information-request must fail before returning options-only metadata. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:9::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await + .expect_err("DHCPv6 information-request without a v6 prefix should fail"); + assert_eq!(status.code(), tonic::Code::FailedPrecondition); + assert!(status.message().contains("without an IPv6 prefix")); + + // Stateful solicit must fail for the same segment configuration. + let status = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:9::1", + RPC_MESSAGE_KIND_V6_SOLICIT, + )) + .await + .expect_err("DHCPv6 solicit without a v6 prefix should fail"); + assert_eq!(status.code(), tonic::Code::FailedPrecondition); + assert!(status.message().contains("without an IPv6 prefix")); + + // The failed requests must not persist an observed interface row. + let mut txn = pool.begin().await?; + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert!(interfaces.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// SLAAC-only first contact should consume the pending predicted interface and +// attach the observed row to the machine, even though it does not allocate DHCP. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_promotes_predicted_interface( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_host_inband(pool.clone()).await; + let mock_host = ManagedHostConfig { + dpus: vec![], + ..ManagedHostConfig::default() + }; + let mac = *mock_host.non_dpu_macs.first().unwrap(); + + let _mock = site_explorer::ingest_zero_dpu_host_awaiting_first_lease(&env, mock_host).await?; + + let (machine_id, host_inband_segment_id) = { + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac) + .await? + .expect("zero-DPU ingest should have minted a predicted interface"); + let host_inband_segment = db::network_segment::for_relay( + &mut txn, + FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(), + ) + .await? + .expect("host-inband segment should resolve from fixture gateway"); + assert!( + db::machine_interface::find_by_mac_address(&mut *txn, mac) + .await? + .is_empty(), + "the in-band NIC should not have a machine_interfaces row yet", + ); + txn.rollback().await?; + (predicted.machine_id, host_inband_segment.id) + }; + + add_ipv6_prefix(&pool, host_inband_segment_id, "2001:db8:b::/64", None).await?; + env.api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:b::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await?; + + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac).await?; + assert!(predicted.is_none()); + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!(interfaces[0].machine_id, Some(machine_id)); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interfaces[0].id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + expected_slaac_address("2001:db8:b::".parse()?, mac) + ); + txn.rollback().await?; + + Ok(()) +} + +// Reserved segments block anonymous observation, but a predicted interface is +// explicit identity and should still be promoted through the common path. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_on_reserved_segment_promotes_predicted_interface( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_host_inband(pool.clone()).await; + let mock_host = ManagedHostConfig { + dpus: vec![], + ..ManagedHostConfig::default() + }; + let mac = *mock_host.non_dpu_macs.first().unwrap(); + + // Ingest a zero-DPU host so the INFO_REQUEST must consume a prediction. + let _mock = site_explorer::ingest_zero_dpu_host_awaiting_first_lease(&env, mock_host).await?; + + // Capture the predicted machine and target segment before DHCP promotion. + let (machine_id, host_inband_segment_id) = { + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac) + .await? + .expect("zero-DPU ingest should have minted a predicted interface"); + let host_inband_segment = db::network_segment::for_relay( + &mut txn, + FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(), + ) + .await? + .expect("host-inband segment should resolve from fixture gateway"); + txn.rollback().await?; + (predicted.machine_id, host_inband_segment.id) + }; + + // Make the predicted segment IPv6-capable but reserved-only. + add_ipv6_prefix(&pool, host_inband_segment_id, "2001:db8:86::/64", None).await?; + set_segment_reserved(&pool, host_inband_segment_id).await?; + + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + "2001:db8:86::1", + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert_eq!(response.machine_id, Some(machine_id)); + assert_eq!(response.segment_id, Some(host_inband_segment_id)); + + // Verify prediction cleanup, interface promotion, and no SLAAC persistence. + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac).await?; + assert!(predicted.is_none()); + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!(interfaces[0].machine_id, Some(machine_id)); + assert_eq!(interfaces[0].segment_id, host_inband_segment_id); + assert!(interfaces[0].last_dhcp.is_some()); + assert_eq!(response.machine_interface_id, Some(interfaces[0].id)); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interfaces[0].id).await?; + assert!(addresses.is_empty()); + txn.rollback().await?; + + Ok(()) +} + +// Exact DHCPv6 link-address routing must beat a reserved prefix-containing +// candidate, so INFORMATION-REQUEST still promotes and observes on the exact segment. +#[crate::sqlx_test] +async fn test_dhcp_v6_info_request_exact_link_precedes_reserved_prefix_candidate( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_host_inband(pool.clone()).await; + let mock_host = ManagedHostConfig { + dpus: vec![], + ..ManagedHostConfig::default() + }; + let mac = *mock_host.non_dpu_macs.first().unwrap(); + let relay = "2001:db8:73::1"; + let reserved_segment = NetworkSegmentId::from_str("00000000-0000-0000-0000-000000000203")?; + + // Ingest a zero-DPU host so the INFO_REQUEST must consume a prediction. + let _mock = site_explorer::ingest_zero_dpu_host_awaiting_first_lease(&env, mock_host).await?; + + // Capture the predicted machine and exact target segment before DHCP promotion. + let (machine_id, exact_segment) = { + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac) + .await? + .expect("zero-DPU ingest should have minted a predicted interface"); + let host_inband_segment = db::network_segment::for_relay( + &mut txn, + FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(), + ) + .await? + .expect("host-inband segment should resolve from fixture gateway"); + txn.rollback().await?; + (predicted.machine_id, host_inband_segment.id) + }; + + // Add a reserved prefix candidate that contains the relay address. + create_admin_network_segment_with_id( + &env, + reserved_segment, + "ADMIN_V6_RESERVED_PREFIX_COMPETES", + "192.0.73.0/24", + "192.0.73.1", + ) + .await?; + add_ipv6_prefix(&pool, reserved_segment, "2001:db8:73::/64", None).await?; + set_segment_reserved(&pool, reserved_segment).await?; + + // Add the authoritative exact DHCPv6 link-address on the predicted segment. + add_ipv6_prefix(&pool, exact_segment, "2001:db8:74::/64", Some(relay)).await?; + let response = env + .api + .discover_dhcp(dhcpv6_discovery( + mac, + relay, + RPC_MESSAGE_KIND_V6_INFO_REQUEST, + )) + .await? + .into_inner(); + assert_eq!(response.address, ""); + assert_eq!(response.prefix, ""); + assert_eq!(response.segment_id, Some(exact_segment)); + + // Verify the request promoted the prediction and updated the exact segment row. + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac).await?; + assert!(predicted.is_none()); + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!(interfaces[0].machine_id, Some(machine_id)); + assert_eq!(interfaces[0].segment_id, exact_segment); + assert!(interfaces[0].last_dhcp.is_some()); + assert_eq!(response.machine_interface_id, Some(interfaces[0].id)); + + // Verify SLAAC observation used the exact segment prefix, not the reserved prefix. + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interfaces[0].id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Slaac); + assert_eq!( + addresses[0].address, + expected_slaac_address("2001:db8:74::".parse()?, mac) + ); + txn.rollback().await?; + + Ok(()) +} + +// Stateful DHCPv6 first contact should promote the predicted interface through +// the selected link-address without allocating an IPv4 DHCP row. +#[crate::sqlx_test] +async fn test_dhcp_v6_solicit_promotes_predicted_interface_by_link_address( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env_with_host_inband(pool.clone()).await; + let mock_host = ManagedHostConfig { + dpus: vec![], + ..ManagedHostConfig::default() + }; + let mac = *mock_host.non_dpu_macs.first().unwrap(); + + // Ingest a zero-DPU host so the DHCP request must consume a prediction. + let _mock = site_explorer::ingest_zero_dpu_host_awaiting_first_lease(&env, mock_host).await?; + + // Capture the predicted machine and target segment before DHCP promotion. + let (machine_id, host_inband_segment_id) = { + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac) + .await? + .expect("zero-DPU ingest should have minted a predicted interface"); + let host_inband_segment = db::network_segment::for_relay( + &mut txn, + FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(), + ) + .await? + .expect("host-inband segment should resolve from fixture gateway"); + assert!( + db::machine_interface::find_by_mac_address(&mut *txn, mac) + .await? + .is_empty(), + "the in-band NIC should not have a machine_interfaces row yet", + ); + txn.rollback().await?; + (predicted.machine_id, host_inband_segment.id) + }; + + // Make the predicted segment dual-stack and identify it by DHCPv6 link-address. + add_ipv6_prefix( + &pool, + host_inband_segment_id, + "2001:db8:c::/64", + Some("2001:db8:d::1"), + ) + .await?; + // Send an unmatchable raw relay with the configured link-address selected. + let mut request = dhcpv6_discovery(mac, "2001:db8:ffff::1", RPC_MESSAGE_KIND_V6_SOLICIT); + request.get_mut().link_address = Some("2001:db8:d::1".to_string()); + let response = env.api.discover_dhcp(request).await?.into_inner(); + let response_address: IpAddr = response.address.parse()?; + assert!(response_address.is_ipv6()); + + // Verify prediction cleanup and the single persisted DHCPv6 allocation. + let mut txn = pool.begin().await?; + let predicted = db::predicted_machine_interface::find_by_mac_address(&mut txn, mac).await?; + assert!(predicted.is_none()); + let interfaces = db::machine_interface::find_by_mac_address(&mut *txn, mac).await?; + assert_eq!(interfaces.len(), 1); + assert_eq!(interfaces[0].machine_id, Some(machine_id)); + assert_eq!(response.machine_interface_id, Some(interfaces[0].id)); + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interfaces[0].id).await?; + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0].allocation_type, AllocationType::Dhcp); + assert_eq!(addresses[0].address, response_address); + txn.rollback().await?; + + Ok(()) +} + /// Resolve a machine_interface + its segment gateway for the given host, so /// the test can drive a DHCP request with the same relay the real host would /// see in production. diff --git a/crates/api-core/src/tests/static_address_management.rs b/crates/api-core/src/tests/static_address_management.rs index 3f2be7b402..bc99ec78b8 100644 --- a/crates/api-core/src/tests/static_address_management.rs +++ b/crates/api-core/src/tests/static_address_management.rs @@ -520,7 +520,7 @@ async fn test_assign_external_ip_moves_to_static_assignments( /// addresses outside managed prefixes must still land on the durable /// static-assignment topology. #[crate::sqlx_test] -async fn test_assign_external_ipv6_moves_to_dual_stack_static_assignments( +async fn test_assign_external_ipv6_matching_link_address_moves_to_dual_stack_static_assignments( pool: sqlx::PgPool, ) -> Result<(), Box> { let env = create_test_env(pool).await; @@ -539,8 +539,18 @@ async fn test_assign_external_ipv6_moves_to_dual_stack_static_assignments( db::machine_interface_address::delete(&mut txn, &interface.id).await?; txn.commit().await?; - // Assign an external IPv6 address that is outside managed network prefixes. + // Mark the external address as DHCPv6 relay context on a managed segment. + // Static ownership must still use prefix containment only. let requested_ipv6_address: IpAddr = "2001:db8:ffff::100".parse().unwrap(); + let mut txn = env.pool.begin().await?; + sqlx::query("UPDATE network_prefixes SET dhcpv6_link_address = $2::inet WHERE segment_id = $1") + .bind(env.admin_segment()) + .bind(requested_ipv6_address) + .execute(&mut *txn) + .await?; + txn.commit().await?; + + // Assign an external IPv6 address that is outside managed network prefixes. env.api .assign_static_address(Request::new(AssignStaticAddressRequest { interface_id: Some(interface.id), diff --git a/crates/api-core/tests/integration/dhcp_lease_expiration.rs b/crates/api-core/tests/integration/dhcp_lease_expiration.rs index cf4aab0c6a..0fde7e2126 100644 --- a/crates/api-core/tests/integration/dhcp_lease_expiration.rs +++ b/crates/api-core/tests/integration/dhcp_lease_expiration.rs @@ -24,6 +24,7 @@ use carbide_test_harness::TestNetworkSegment; use carbide_test_harness::prelude::*; use mac_address::MacAddress; use model::address_selection_strategy::AddressSelectionStrategy; +use model::allocation_type::AllocationType; use rpc::forge::forge_server::Forge; use rpc::forge::{DhcpDiscovery, ExpireDhcpLeaseRequest, ExpireDhcpLeaseStatus}; use tonic::Request; @@ -261,6 +262,53 @@ async fn test_expire_does_not_delete_static_allocation( Ok(()) } +#[sqlx_test] +async fn test_expire_does_not_delete_slaac_allocation( + pool: PgPool, +) -> Result<(), Box> { + let (env, admin_segment) = init(pool).await; + let relay: std::net::IpAddr = admin_segment.relay_address; + let mac = MacAddress::from_str("aa:bb:cc:dd:ee:0e").unwrap(); + let slaac_ip: IpAddr = "2001:db8:2::ff:fe00:e".parse().unwrap(); + + // Create a normal DHCP interface, then add an observed SLAAC address. + let mut txn = env.db_txn().await; + let interface = db::machine_interface::validate_existing_mac_and_create( + &mut txn, + mac, + std::slice::from_ref(&relay), + None, + None, + ) + .await?; + db::machine_interface_address::insert(&mut txn, interface.id, slaac_ip, AllocationType::Slaac) + .await?; + txn.commit().await?; + + // Try to expire the SLAAC address; only DHCP allocations are releasable. + let response = env + .api() + .expire_dhcp_lease(Request::new(ExpireDhcpLeaseRequest { + ip_address: slaac_ip.to_string(), + mac_address: Some(mac.to_string()), + })) + .await? + .into_inner(); + assert_eq!(response.status(), ExpireDhcpLeaseStatus::NotFound); + + // Verify through a fresh DB read that the SLAAC row remained. + let mut txn = env.db_txn().await; + let addresses = + db::machine_interface_address::find_for_interface(&mut txn, interface.id).await?; + txn.commit().await?; + assert!( + addresses.iter().any(|address| address.address == slaac_ip + && address.allocation_type == AllocationType::Slaac) + ); + + Ok(()) +} + #[sqlx_test] async fn test_static_address_survives_expiration_and_rediscover( pool: PgPool, diff --git a/crates/api-db/src/dhcp_record.rs b/crates/api-db/src/dhcp_record.rs index 90130c3ef4..d36e88c224 100644 --- a/crates/api-db/src/dhcp_record.rs +++ b/crates/api-db/src/dhcp_record.rs @@ -17,6 +17,7 @@ use carbide_network::ip::IpAddressFamily; use carbide_uuid::network::NetworkSegmentId; +use chrono::{DateTime, Utc}; use mac_address::MacAddress; use model::dhcp_record::DhcpRecord; use sqlx::PgConnection; @@ -38,3 +39,14 @@ pub async fn find_by_mac_address( .await .map_err(|e| DatabaseError::query(query, e)) } + +/// Return the global DHCP record invalidation timestamp. +pub async fn last_invalidation_time( + txn: &mut PgConnection, +) -> Result, DatabaseError> { + let query = "SELECT last_deletion FROM machine_interfaces_deletion WHERE id = 1"; + sqlx::query_scalar(query) + .fetch_one(txn) + .await + .map_err(|e| DatabaseError::query(query, e)) +} diff --git a/crates/api-db/src/machine.rs b/crates/api-db/src/machine.rs index bfcd6ba9a7..3caacb58d0 100644 --- a/crates/api-db/src/machine.rs +++ b/crates/api-db/src/machine.rs @@ -158,6 +158,8 @@ pub async fn find_existing_machine( macaddr: MacAddress, relay: IpAddr, ) -> Result, DatabaseError> { + // Exact DHCPv6 link-address matches are authoritative. Prefix containment + // is only valid when no segment claims the relay by exact link-address. let query = " SELECT m.id FROM machines m @@ -165,14 +167,27 @@ pub async fn find_existing_machine( ON m.id = mi.machine_id INNER JOIN network_segments ns ON mi.segment_id = ns.id - INNER JOIN network_prefixes np - ON np.segment_id = ns.id WHERE mi.mac_address = $1::macaddr AND mi.interface_type != 'Bmc' AND - $2::inet <<= np.prefix"; + EXISTS ( + SELECT 1 + FROM network_prefixes np + WHERE np.segment_id = ns.id + AND ( + np.dhcpv6_link_address = $2::inet + OR ( + NOT EXISTS ( + SELECT 1 + FROM network_prefixes exact_np + WHERE exact_np.dhcpv6_link_address = $2::inet + ) + AND $2::inet <<= np.prefix + ) + ) + )"; let id: Option = sqlx::query_as(query) .bind(macaddr) diff --git a/crates/api-db/src/machine_interface.rs b/crates/api-db/src/machine_interface.rs index 4612c37049..3801572d3d 100644 --- a/crates/api-db/src/machine_interface.rs +++ b/crates/api-db/src/machine_interface.rs @@ -457,6 +457,9 @@ pub async fn find_one( // newly_created_interface indicates that we couldn't find a // MachineInterface, so created new one. // +// DHCPv4 and DHCPv6 for the same NIC intentionally converge on the same +// machine_interfaces row through the `(segment_id, mac_address)` invariant. +// // `is_primary` carries the declared `ExpectedHostNic.primary` for this MAC: // `Some(true)` -- this NIC is the host's declared boot interface, `Some(false)` // -- a different NIC is, `None` -- nothing was declared. On a newly created (and @@ -466,6 +469,13 @@ pub async fn find_one( // // If we're not making a new interface, then existing interfaces // are returned untouched. +/// Optional metadata used while finding or creating a DHCP machine interface. +pub struct FindOrCreateMachineInterfaceOptions { + pub host_nic: Option, + pub is_primary: Option, + pub retained_window: Option, +} + pub async fn find_or_create_machine_interface( txn: &mut PgConnection, machine_id: Option, @@ -475,6 +485,55 @@ pub async fn find_or_create_machine_interface( is_primary: Option, retained_window: Option, ) -> DatabaseResult { + find_or_create_machine_interface_inner( + txn, + machine_id, + mac_address, + relays, + FindOrCreateMachineInterfaceOptions { + host_nic, + is_primary, + retained_window, + }, + None, + ) + .await +} + +/// Find or create a DHCP interface, allocating only the requested family for a +/// brand-new dynamic row. +pub async fn find_or_create_machine_interface_for_family( + txn: &mut PgConnection, + machine_id: Option, + mac_address: MacAddress, + relays: &[IpAddr], + options: FindOrCreateMachineInterfaceOptions, + address_family: IpAddressFamily, +) -> DatabaseResult { + find_or_create_machine_interface_inner( + txn, + machine_id, + mac_address, + relays, + options, + Some(address_family), + ) + .await +} + +async fn find_or_create_machine_interface_inner( + txn: &mut PgConnection, + machine_id: Option, + mac_address: MacAddress, + relays: &[IpAddr], + options: FindOrCreateMachineInterfaceOptions, + address_family: Option, +) -> DatabaseResult { + let FindOrCreateMachineInterfaceOptions { + host_nic, + is_primary, + retained_window, + } = options; let relaystr = relays .iter() .map(|v| v.to_string()) @@ -486,31 +545,119 @@ pub async fn find_or_create_machine_interface( %mac_address, "Found no existing machine with mac address {mac_address} using networks with relays {relaystr}", ); - let mut interface = validate_existing_mac_and_create( + let mut interface = validate_existing_mac_and_create_inner( &mut *txn, mac_address, relays, host_nic, retained_window, + address_family, ) .await?; - // Make the declaration authoritative on this machine-less row. - // `validate_existing_mac_and_create` defaults a freshly created row to - // primary, so the demote covers "a different NIC is declared primary" - // and the promote covers a row we *found* (rather than created) that is - // the declared primary. Safe on a NULL machine_id row: the - // one_primary_interface_per_machine index does not constrain it. - match is_primary { - Some(false) if interface.primary_interface => { - set_primary_interface(&interface.id, false, &mut *txn).await?; - interface.primary_interface = false; - } - Some(true) if !interface.primary_interface => { - set_primary_interface(&interface.id, true, &mut *txn).await?; - interface.primary_interface = true; + apply_primary_declaration(&mut *txn, &mut interface, is_primary).await?; + Ok(interface) + } + Some(_) => { + let mut ifcs = find_by_mac_address(&mut *txn, mac_address).await?; + match ifcs.len() { + 1 => Ok(ifcs.remove(0)), + n => { + tracing::warn!( + %mac_address, + relay_ips = %relaystr, + num_mac_address = n, + "Duplicate mac address for network segment", + ); + Err(DatabaseError::NetworkSegmentDuplicateMacAddress( + mac_address, + )) } - _ => {} } + } + } +} + +/// Find or create a DHCP-seen interface without allocating any addresses. +/// +/// This is used before family-specific DHCP allocation and by DHCPv6 +/// information-request handling, where the packet proves interface presence but +/// must not consume a DHCP lease. +pub async fn find_or_create_observed_machine_interface( + txn: &mut PgConnection, + machine_id: Option, + mac_address: MacAddress, + relays: &[IpAddr], + host_nic: Option, + is_primary: Option, + retained_window: Option, +) -> DatabaseResult { + let relaystr = relays + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(", "); + match machine_id { + None => { + tracing::info!( + %mac_address, + "Found no existing machine with mac address {mac_address} using networks with relays {relaystr}", + ); + + // Return an existing row when the MAC is already known on this segment. + let mut interface_snapshot = find_by_mac_address(&mut *txn, mac_address).await?; + let mut interface = match interface_snapshot.len() { + 0 => { + tracing::debug!( + %mac_address, + "No existing machine_interface with mac address exists yet, creating observed row", + ); + let network_segments = + network_segments_for_dhcp_relays(txn, relays, host_nic.as_ref()).await?; + + if network_segments.is_empty() { + return Err(DatabaseError::internal(format!( + "No network segment defined for relay addresses: {:?}", + relays + ))); + } + + let segment = &network_segments[0]; + + // Observed-only rows are specific to DHCPv6 INFORMATION-REQUEST and + // are created on the selected first candidate. With one relay, additional + // candidates are fallback matches such as prefix containment behind an exact + // DHCPv6 link-address and must not veto the selected segment. + if segment.config.allocation_strategy == AllocationStrategy::Reserved { + return Err(DatabaseError::internal(format!( + "segment {} configured for static DHCP leases only; no static reservation for MAC {mac_address}", + segment.config.name, + ))); + } + + create_without_addresses(txn, segment, &mac_address, true, retained_window) + .await? + } + 1 => { + tracing::debug!( + %mac_address, + "Mac address exists, validating the relay and returning it", + ); + let mut existing_interface = interface_snapshot.remove(0); + reconcile_interface_segment(txn, &mut existing_interface, relays).await?; + existing_interface + } + _ => { + tracing::warn!( + %mac_address, + "More than one existing mac address for network segment", + ); + return Err(DatabaseError::NetworkSegmentDuplicateMacAddress( + mac_address, + )); + } + }; + + apply_primary_declaration(txn, &mut interface, is_primary).await?; Ok(interface) } Some(_) => { @@ -533,13 +680,127 @@ pub async fn find_or_create_machine_interface( } } -/// Do basic validating on existing macs and create the interface if it does not exist +/// Apply the expected-host primary declaration to an anonymous interface row. +async fn apply_primary_declaration( + txn: &mut PgConnection, + interface: &mut MachineInterfaceSnapshot, + is_primary: Option, +) -> DatabaseResult<()> { + // The declaration is safe on NULL-machine rows because the primary-interface + // uniqueness index does not constrain them. + match is_primary { + Some(false) if interface.primary_interface => { + set_primary_interface(&interface.id, false, &mut *txn).await?; + interface.primary_interface = false; + } + Some(true) if !interface.primary_interface => { + set_primary_interface(&interface.id, true, &mut *txn).await?; + interface.primary_interface = true; + } + _ => {} + } + Ok(()) +} + +/// Resolve DHCP candidate network segments for a relay list and optional NIC type. +pub async fn network_segments_for_dhcp_relays( + txn: &mut PgConnection, + relays: &[IpAddr], + host_nic: Option<&ExpectedHostNic>, +) -> DatabaseResult> { + let expected_network_segment_type = + host_nic.and_then(ExpectedHostNic::resolved_network_segment_type); + let network_segments = db_network_segment::for_relay_all(txn, relays).await?; + let exact_segment_ids = exact_dhcpv6_link_address_segment_ids(&network_segments, relays); + if !exact_segment_ids.is_empty() { + let exact_segments = network_segments + .into_iter() + .filter(|segment| exact_segment_ids.contains(&segment.id)) + .collect::>(); + + if let Some(network_segment_type) = expected_network_segment_type + && exact_segments + .iter() + .any(|segment| segment.config.segment_type != network_segment_type) + { + tracing::warn!( + relay_ips = %relays.iter().join(", "), + expected_network_segment_type = %network_segment_type, + exact_segment_ids = %exact_segments.iter().map(|segment| segment.id.to_string()).join(", "), + exact_segment_types = %exact_segments + .iter() + .map(|segment| segment.config.segment_type.to_string()) + .join(", "), + "DHCPv6 exact link-address segment type differs from ExpectedHostNic segment type; using exact link-address segment" + ); + } + + return Ok(exact_segments); + } + + // A declared NIC narrows prefix-based segment selection to a specific type: + // exact DHCPv6 link-address matches are authoritative and bypass this hint. + if let Some(network_segment_type) = expected_network_segment_type { + Ok(network_segments + .into_iter() + .filter(|segment| segment.config.segment_type == network_segment_type) + .collect()) + } else { + Ok(network_segments) + } +} + +/// Returns relay candidate IDs that exactly match a DHCPv6 link-address. +/// +/// `network_prefixes.dhcpv6_link_address` is unique at the DB layer, so a +/// single relay/link-address can produce at most one exact segment. Multiple +/// IDs here mean the caller supplied multiple distinct relay inputs. +fn exact_dhcpv6_link_address_segment_ids( + network_segments: &[NetworkSegment], + relays: &[IpAddr], +) -> Vec { + network_segments + .iter() + .filter(|segment| { + segment.prefixes.iter().any(|prefix| { + prefix + .dhcpv6_link_address + .is_some_and(|link_address| relays.contains(&link_address)) + }) + }) + .map(|segment| segment.id) + .collect() +} + +/// Do basic validating on existing MACs and create the interface if it does not exist. pub async fn validate_existing_mac_and_create( txn: &mut PgConnection, mac_address: MacAddress, relays: &[IpAddr], host_nic: Option, retained_window: Option, +) -> DatabaseResult { + validate_existing_mac_and_create_inner( + txn, + mac_address, + relays, + host_nic, + retained_window, + None, + ) + .await +} + +/// If `address_family` is provided, it is applied only when this call creates +/// a new dynamic interface: candidate segment snapshots are filtered to that +/// family before `create` runs. Existing MAC reconciliation ignores the filter. +async fn validate_existing_mac_and_create_inner( + txn: &mut PgConnection, + mac_address: MacAddress, + relays: &[IpAddr], + host_nic: Option, + retained_window: Option, + address_family: Option, ) -> DatabaseResult { let mut interface_snapshot = find_by_mac_address(&mut *txn, mac_address).await?; match &interface_snapshot.len() { @@ -549,28 +810,29 @@ pub async fn validate_existing_mac_and_create( "No existing machine_interface with mac address exists yet, creating one", ); - // A declared NIC narrows segment selection to a specific type: when - // the relay's prefix matches more than one segment (nested or - // overlapping prefixes), pick the one of the declared type -- the - // typed `network_segment_type`, or the legacy `nic_type` it - // supersedes. Otherwise the relay's matching segment(s) stand. - let segment_type = host_nic - .as_ref() - .and_then(ExpectedHostNic::resolved_network_segment_type); - - let network_segments = if let Some(network_segment_type) = segment_type { - // Declared type -> the relay's segments of that type only. - db_network_segment::for_segment_type_all(txn, relays, network_segment_type).await? - } else { - // No declaration -> every segment the relay's prefix matches. - db_network_segment::for_relay_all(txn, relays).await? - }; + let mut network_segments = + network_segments_for_dhcp_relays(txn, relays, host_nic.as_ref()).await?; if !network_segments.is_empty() { - // If the segment only allows static reservations, reject - // dynamic allocation. The device must have a pre-existing - // static reservation to get an IP on this segment. - for segment in network_segments.iter() { + let exact_segment_ids = + exact_dhcpv6_link_address_segment_ids(&network_segments, relays); + let authoritative_segment_ids = if exact_segment_ids.is_empty() { + network_segments + .iter() + .map(|segment| segment.id) + .collect::>() + } else { + exact_segment_ids.clone() + }; + + // IPv4 relay resolution has only prefix candidates, so the legacy + // "any reserved candidate vetoes dynamic DHCP" behavior remains. + // DHCPv6 link-address can be authoritative while a later prefix + // fallback is reserved; only authoritative candidates may veto. + for segment in network_segments + .iter() + .filter(|segment| authoritative_segment_ids.contains(&segment.id)) + { if segment.config.allocation_strategy == AllocationStrategy::Reserved { return Err(DatabaseError::internal(format!( "segment {} configured for static DHCP leases only; no static reservation for MAC {mac_address}", @@ -579,6 +841,40 @@ pub async fn validate_existing_mac_and_create( } } + if !exact_segment_ids.is_empty() { + // Exact DHCPv6 link-address is a segment selector. IPv4 has + // only prefix candidates and keeps fallback behavior; exact + // DHCPv6 fails on that segment if it is v6-disabled or exhausted. + network_segments.retain(|segment| exact_segment_ids.contains(&segment.id)); + } + + if let Some(address_family) = address_family { + let candidate_segment_ids = network_segments + .iter() + .map(|segment| segment.id.to_string()) + .join(", "); + + // Reuse the existing dynamic create path unchanged: each + // candidate snapshot now exposes only the requested family, + // so its normal retry and lock strategy stays correct. + network_segments.retain_mut(|segment| { + segment + .prefixes + .retain(|prefix| prefix.prefix.is_address_family(address_family)); + !segment.prefixes.is_empty() + }); + + if network_segments.is_empty() { + let family_label = match address_family { + IpAddressFamily::Ipv4 => "IPv4", + IpAddressFamily::Ipv6 => "IPv6", + }; + return Err(DatabaseError::FailedPrecondition(format!( + "DHCP request received for candidate network segments {candidate_segment_ids} without an {family_label} prefix", + ))); + } + } + // Dynamic-pool allocation. // Any AddressSelectionStrategy::StaticIp flows will have happened as part of // preallocate_machine_interface or preallocate_bmc_machine_interface. @@ -677,10 +973,13 @@ pub async fn preallocate_bmc_machine_interface( /// If a machine interface row already exists for `mac_address`, reconcile it against the /// requested (`static_ip`, `interface_type`): -/// - Returns `Ok(true)` when an existing row carries `static_ip`. Promotes `interface_type` -/// (and clears `primary_interface` for Bmc) if those don't already match. +/// - Returns `Ok(true)` when an existing row can carry `static_ip`. Promotes +/// `interface_type` (and clears `primary_interface` for Bmc) if those don't already match. +/// Existing DHCP/SLAAC rows for the same address family are replaced by the static +/// reservation. /// - Returns `Ok(false)` when no row exists for `mac_address` — caller should create. -/// - Returns `Err(InvalidArgument)` when a row exists but carries different addresses. +/// - Returns `Err(InvalidArgument)` when a row exists but carries a different static address +/// for the requested address family. async fn reconcile_existing_preallocation( txn: &mut PgConnection, mac_address: MacAddress, @@ -691,12 +990,59 @@ async fn reconcile_existing_preallocation( let Some(iface) = existing.first() else { return Ok(false); }; - if !iface.addresses.contains(&static_ip) { - return Err(DatabaseError::InvalidArgument(format!( - "a machine interface already exists for MAC {mac_address} with addresses {:?}; use update to change the IP address", - iface.addresses, - ))); + + let family = static_ip.address_family(); + let addresses = + crate::machine_interface_address::find_for_interface(&mut *txn, iface.id).await?; + let same_family = addresses + .iter() + .find(|address| address.address.is_address_family(family)); + match same_family { + Some(address) + if address.address != static_ip + && address.allocation_type == AllocationType::Static => + { + return Err(DatabaseError::InvalidArgument(format!( + "a machine interface already exists for MAC {mac_address} with addresses {:?}; use update to change the IP address", + iface.addresses, + ))); + } + Some(address) + if address.address == static_ip + && address.allocation_type == AllocationType::Static => {} + _ => { + if let Some(existing_addr) = + crate::machine_interface_address::find_by_address(&mut *txn, static_ip).await? + && existing_addr.id != iface.id + { + return Err(DatabaseError::InvalidArgument(format!( + "IP address {static_ip} is already allocated to interface {} on segment {}; use 'machine-interfaces assign-address' to reassign it", + existing_addr.id, existing_addr.name, + ))); + } + let target_segment = + match db_network_segment::for_prefix_containing_address(&mut *txn, static_ip) + .await? + { + Some(segment) => segment, + None => db_network_segment::static_assignments(&mut *txn).await?, + }; + if iface.segment_id != target_segment.id { + return Err(DatabaseError::InvalidArgument(format!( + "a machine interface already exists for MAC {mac_address} on segment {}; fixed IP {static_ip} belongs to segment {}; use update to change the segment", + iface.segment_id, target_segment.id, + ))); + } + crate::machine_interface_address::assign_static(&mut *txn, iface.id, static_ip).await?; + sync_hostname_after_address_assignment( + &mut *txn, + iface.id, + target_segment.config.subdomain_id, + ) + .await?; + } } + if iface.interface_type != interface_type { set_interface_type(&iface.id, interface_type, txn).await?; } @@ -729,10 +1075,11 @@ async fn preallocate_machine_interface_with_type( ))); } - let segment = match db_network_segment::for_relay(&mut *txn, static_ip).await? { - Some(seg) => seg, - None => db_network_segment::static_assignments(&mut *txn).await?, - }; + let segment = + match db_network_segment::for_prefix_containing_address(&mut *txn, static_ip).await? { + Some(seg) => seg, + None => db_network_segment::static_assignments(&mut *txn).await?, + }; match create_with_type( txn, @@ -1192,6 +1539,57 @@ async fn allocate_v6_addresses_via_ip_allocator( Ok(allocated_addresses) } +/// Create a machine interface row without inserting address rows. +/// +/// This preserves the normal hostname and retained boot-interface behavior for +/// observation-only DHCPv6 paths that must not consume a DHCP allocation. +async fn create_without_addresses( + txn: &mut PgConnection, + segment: &NetworkSegment, + macaddr: &MacAddress, + primary_interface: bool, + retained_window: Option, +) -> DatabaseResult { + // A brand-new observed row has no address yet, so naming uses the dormant placeholder. + let ctx = NamingContext { + mac_address: *macaddr, + addresses: &[], + current_hostname: None, + machine_id: None, + is_primary: primary_interface, + interface_type: InterfaceType::Data, + interface_id: None, + domain_id: segment.config.subdomain_id, + }; + let hostname = host_naming::hostname_for(txn, &ctx).await?; + + // Insert only the interface identity; address observation/allocation happens later. + let interface_id = insert_machine_interface( + txn, + &segment.id, + macaddr, + hostname, + segment.config.subdomain_id, + primary_interface, + InterfaceType::Data, + ) + .await?; + + let mut snapshot = find_by(&mut *txn, ObjectColumnFilter::One(IdColumn, &interface_id)) + .await? + .remove(0); + if snapshot.boot_interface_id.is_none() + && let Some(boot_interface_id) = + crate::retained_boot_interface::take_by_mac(&mut *txn, *macaddr, retained_window) + .await? + { + set_boot_interface_id(*macaddr, &boot_interface_id, &mut *txn).await?; + snapshot.boot_interface_id = Some(boot_interface_id); + } + + Ok(snapshot) +} + /// Create the actual machine interface once we know what addresses we want. #[allow(clippy::too_many_arguments)] async fn create_inner( @@ -1594,16 +1992,14 @@ pub async fn move_predicted_machine_interface_to_machine( false, ), None => { - // This host has never DHCP'd before, create a new machine_interface for it - // (`create` recovers any retained boot interface id onto it). The promoted row - // is primary exactly when the prediction carries the declared - // `ExpectedHostNic.primary`. - let machine_interface = create( + // This host has never DHCP'd before, create only the interface + // identity. The DHCP handler allocates the requested address + // family after promotion. + let machine_interface = create_without_addresses( txn, - &[network_segment], + &network_segment, &predicted_machine_interface.mac_address, predicted_machine_interface.primary_interface, - AddressSelectionStrategy::NextAvailableIp, retained_window, ) .await?; @@ -2297,6 +2693,34 @@ pub async fn sync_hostname_after_address_change( Ok(()) } +/// Syncs hostname/domain after an address assignment. +/// +/// Address-bearing interfaces rejoin the owning segment's domain so DHCP/DNS +/// projections can find them; addressless interfaces remain DNS-silent. +pub async fn sync_hostname_after_address_assignment( + txn: &mut PgConnection, + interface_id: MachineInterfaceId, + domain_id: Option, +) -> DatabaseResult<()> { + // Read fresh address state before deriving the hostname and DNS domain. + let mut snapshot = find_one(&mut *txn, interface_id).await?; + snapshot.addresses.sort(); + let domain_id = if snapshot.addresses.is_empty() { + None + } else { + domain_id + }; + + // Derive the hostname under the target domain and write both together. + let ctx = NamingContext { + domain_id, + ..NamingContext::from_snapshot(&snapshot) + }; + let hostname = host_naming::hostname_for(&mut *txn, &ctx).await?; + update_hostname_and_domain(txn, interface_id, &hostname, domain_id).await?; + Ok(()) +} + pub async fn find_by_machine_and_segment( txn: &mut PgConnection, machine_id: &MachineId, @@ -2357,13 +2781,19 @@ async fn reconcile_interface_segment( relays.iter().join(", ") ))); }; + let exact_segment_ids = exact_dhcpv6_link_address_segment_ids(&relay_segments, relays); + let authoritative_segment_ids = if exact_segment_ids.is_empty() { + relay_segments + .iter() + .map(|segment| segment.id) + .collect::>() + } else { + exact_segment_ids + }; - // If the existing interface belongs to any candidate segment, it is valid. - // This handles proactive DPU ingestion where all admin gateways are candidates. - if relay_segments - .iter() - .any(|n| n.id == existing_interface.segment_id) - { + // Prefix fallback candidates remain useful for allocation fallback, but an + // exact DHCPv6 link-address is authoritative for existing-MAC ownership. + if authoritative_segment_ids.contains(&existing_interface.segment_id) { return Ok(()); } @@ -2379,12 +2809,20 @@ async fn reconcile_interface_segment( // removed the static allocation on purpose, and now we're waiting for // the device to DHCP so we can see what segment it's coming in on. if on_static_assignments && existing_interface.addresses.is_empty() { - let [relay_segment] = relay_segments.as_slice() else { + let [relay_segment_id] = authoritative_segment_ids.as_slice() else { return Err(DatabaseError::internal(format!( "Cannot move interface from static-assignments with multiple candidate relays: {} ", relays.iter().join(", ") ))); }; + let relay_segment = relay_segments + .iter() + .find(|segment| segment.id == *relay_segment_id) + .ok_or_else(|| { + DatabaseError::internal(format!( + "Authoritative relay segment {relay_segment_id} was not present in relay candidates" + )) + })?; tracing::info!( mac_address = %existing_interface.mac_address, @@ -2416,9 +2854,9 @@ async fn reconcile_interface_segment( "Network segment mismatch for existing MAC address: {} expected: {} actual from network switch: {}", existing_interface.mac_address, existing_interface.segment_id, - relay_segments + authoritative_segment_ids .iter() - .map(|ns| ns.id.to_string()) + .map(ToString::to_string) .collect::>() .join(", "), ))); @@ -2499,17 +2937,7 @@ pub async fn allocate_address_for_family( return Ok(allocated_addresses); } - // Sync the hostname/domain to the interface's current address set via the - // configured naming strategy. Read the interface back so the strategy sees - // the full set (e.g. an existing IPv4 still wins the name on a later IPv6 - // allocation) and the name it already has. - let mut snapshot = find_one(&mut *txn, interface_id).await?; - // The snapshot aggregates addresses in no particular order; sort them so - // the derived name is stable across events. - snapshot.addresses.sort(); - let hostname = - host_naming::hostname_for(&mut *txn, &NamingContext::from_snapshot(&snapshot)).await?; - update_hostname_and_domain(txn, interface_id, &hostname, segment.config.subdomain_id).await?; + sync_hostname_after_address_assignment(txn, interface_id, segment.config.subdomain_id).await?; Ok(allocated_addresses) } diff --git a/crates/api-db/src/network_segment.rs b/crates/api-db/src/network_segment.rs index 469c2e15c2..cd471f3d82 100644 --- a/crates/api-db/src/network_segment.rs +++ b/crates/api-db/src/network_segment.rs @@ -189,16 +189,68 @@ pub async fn for_vpc( Ok(results) } +/// Returns the segment matched by a DHCP relay address. +/// +/// Exact DHCPv6 link-address matches win over prefix containment, matching the +/// candidate ordering used by `for_relay_all`. pub async fn for_relay( txn: &mut PgConnection, relay: IpAddr, ) -> DatabaseResult> { let mut results = for_relay_all(txn, std::slice::from_ref(&relay)).await?; + match results.len() { + 0 | 1 => Ok(results.pop()), + _ => { + // DHCPv6 link-address equality is unique and more specific than + // prefix containment, so it resolves the otherwise ambiguous match. + results + .into_iter() + .find(|segment| { + segment + .prefixes + .iter() + .any(|prefix| prefix.dhcpv6_link_address == Some(relay)) + }) + .map(Some) + .ok_or_else(|| { + DatabaseError::internal(format!( + "Multiple network segments defined for relay address {relay}" + )) + }) + } + } +} + +/// Returns the segment whose managed prefix contains `address`. +/// +/// This intentionally ignores `dhcpv6_link_address`: that field is DHCP relay +/// routing context and may be outside the segment prefix. +pub async fn for_prefix_containing_address( + txn: &mut PgConnection, + address: IpAddr, +) -> DatabaseResult> { + static QUERY: &str = concat!( + network_segment_snapshot_query!(), + r#" + WHERE EXISTS ( + SELECT 1 + FROM network_prefixes + WHERE network_prefixes.segment_id = ns.id + AND $1::inet <<= network_prefixes.prefix + ) + ORDER BY ns.id"#, + ); + let mut results: Vec = sqlx::query_as(QUERY) + .bind(IpNetwork::from(address)) + .fetch_all(txn) + .await + .map_err(|e| DatabaseError::query(QUERY, e))?; + match results.len() { 0 | 1 => Ok(results.pop()), _ => Err(DatabaseError::internal(format!( - "Multiple network segments defined for relay address {relay}" + "Multiple network segments contain address {address}" ))), } } @@ -215,12 +267,21 @@ pub async fn for_relay_all( SELECT 1 FROM network_prefixes WHERE network_prefixes.segment_id = ns.id - AND EXISTS ( - SELECT 1 FROM unnest($1::inet[]) AS ip - WHERE ip <<= network_prefixes.prefix + AND ( + EXISTS ( + SELECT 1 FROM unnest($1::inet[]) AS ip + WHERE ip <<= network_prefixes.prefix + ) + OR network_prefixes.dhcpv6_link_address = ANY($1::inet[]) ) ) - ORDER BY ns.id"#, + ORDER BY EXISTS ( + SELECT 1 + FROM network_prefixes + WHERE network_prefixes.segment_id = ns.id + AND network_prefixes.dhcpv6_link_address = ANY($1::inet[]) + ) DESC, + ns.id"#, ); let results = sqlx::query_as(QUERY) .bind( @@ -250,13 +311,22 @@ pub async fn for_segment_type_all( SELECT 1 FROM network_prefixes WHERE network_prefixes.segment_id = ns.id - AND EXISTS ( - SELECT 1 FROM unnest($1::inet[]) AS ip - WHERE ip <<= network_prefixes.prefix + AND ( + EXISTS ( + SELECT 1 FROM unnest($1::inet[]) AS ip + WHERE ip <<= network_prefixes.prefix + ) + OR network_prefixes.dhcpv6_link_address = ANY($1::inet[]) ) ) AND $2 = ns.network_segment_type - ORDER BY ns.id"#, + ORDER BY EXISTS ( + SELECT 1 + FROM network_prefixes + WHERE network_prefixes.segment_id = ns.id + AND network_prefixes.dhcpv6_link_address = ANY($1::inet[]) + ) DESC, + ns.id"#, ); let results = sqlx::query_as(QUERY) @@ -274,22 +344,6 @@ pub async fn for_segment_type_all( Ok(results) } -pub async fn for_segment_type( - txn: &mut PgConnection, - relay: IpAddr, - segment_type: NetworkSegmentType, -) -> DatabaseResult> { - let mut results = for_segment_type_all(txn, std::slice::from_ref(&relay), segment_type).await?; - if results.len() > 1 { - tracing::trace!( - "Multiple network segments defined for segment_type {} and relay address {}", - segment_type.to_string(), - relay.to_string() - ); - } - Ok(results.pop()) -} - /// Retrieves the IDs of all network segments. /// If `segment_type` is specified, only IDs of segments that match the specific type are returned. pub async fn list_segment_ids( @@ -944,6 +998,7 @@ pub async fn allocate_svi_ip( #[cfg(test)] mod tests { + use model::network_prefix::NewNetworkPrefix; use model::network_segment::NetworkDefinitionSegmentType; use super::*; @@ -961,6 +1016,100 @@ mod tests { .fetch_one(pool) .await } + + /// Persists one test segment with a single prefix row. + async fn persist_test_segment( + pool: &sqlx::PgPool, + name: &str, + prefix: &str, + gateway: Option<&str>, + dhcpv6_link_address: Option<&str>, + ) -> Result> { + let mut txn = pool.begin().await?; + let segment = NewNetworkSegment { + id: uuid::Uuid::new_v4().into(), + name: name.to_string(), + subdomain_id: None, + vpc_id: None, + mtu: 1500, + prefixes: vec![NewNetworkPrefix { + prefix: prefix.parse()?, + gateway: gateway.map(str::parse).transpose()?, + dhcpv6_link_address: dhcpv6_link_address.map(str::parse).transpose()?, + num_reserved: 1, + }], + vlan_id: None, + vni: None, + segment_type: NetworkSegmentType::Admin, + can_stretch: None, + allocation_strategy: Default::default(), + }; + let segment_id = segment.id; + + persist(segment, &mut txn, NetworkSegmentControllerState::Ready).await?; + txn.commit().await?; + Ok(segment_id) + } + + #[crate::sqlx_test] + async fn for_prefix_containing_address_ignores_dhcpv6_link_address( + pool: sqlx::PgPool, + ) -> Result<(), Box> { + let link_address = "2001:db8:ffff::1"; + + // A DHCPv6 link-address outside the prefix must not make the segment + // own that address for static assignment. + persist_test_segment( + &pool, + "link-only", + "2001:db8:a::/64", + None, + Some(link_address), + ) + .await?; + let mut txn = pool.begin().await?; + let segment = for_prefix_containing_address(&mut txn, link_address.parse()?).await?; + assert!(segment.is_none()); + txn.rollback().await?; + + // If another segment's real prefix contains the same address, static + // ownership follows the prefix, not the link-address equality branch. + let owner_segment = + persist_test_segment(&pool, "prefix-owner", "2001:db8:ffff::/64", None, None).await?; + let mut txn = pool.begin().await?; + let segment = for_prefix_containing_address(&mut txn, link_address.parse()?) + .await? + .expect("prefix-containing segment should resolve"); + assert_eq!(segment.id, owner_segment); + txn.rollback().await?; + + Ok(()) + } + + #[crate::sqlx_test] + async fn for_relay_prefers_exact_dhcpv6_link_address_over_prefix_containment( + pool: sqlx::PgPool, + ) -> Result<(), Box> { + let relay = "2001:db8:ffff::1"; + + // One segment matches only because its managed prefix contains the relay. + persist_test_segment(&pool, "prefix-owner", "2001:db8:ffff::/64", None, None).await?; + + // The other segment owns the relay by exact DHCPv6 link-address. + let exact_segment = + persist_test_segment(&pool, "link-owner", "2001:db8:a::/64", None, Some(relay)).await?; + + // The exact link-address match should disambiguate the relay lookup. + let mut txn = pool.begin().await?; + let segment = for_relay(&mut txn, relay.parse()?) + .await? + .expect("relay should resolve to exact link-address segment"); + assert_eq!(segment.id, exact_segment); + txn.rollback().await?; + + Ok(()) + } + // A brand-new network is declared but no segment exists yet and no // snapshot has been recorded. // (`create_initial_networks`) is responsible for inserting both the diff --git a/crates/api-model/src/network_segment/mod.rs b/crates/api-model/src/network_segment/mod.rs index bfc259a1f1..3ca57c85e4 100644 --- a/crates/api-model/src/network_segment/mod.rs +++ b/crates/api-model/src/network_segment/mod.rs @@ -207,6 +207,31 @@ impl NetworkSegment { pub fn is_marked_as_deleted(&self) -> bool { self.deleted.is_some() } + + /// Returns the segment's SLAAC-eligible IPv6 /64 prefix, if any. + /// + /// This is a segment-only predicate: relay context, requested addresses, and + /// client identity must not influence it. Per-interface address ownership is + /// checked at the DHCP call site. + pub fn slaac_eligible(&self) -> Option<&IpNetwork> { + if self.config.allocation_strategy == AllocationStrategy::Reserved { + return None; + } + + // The DB currently enforces one prefix per family per segment; keep this + // defensive guard so non-DB callers or future schema changes cannot persist + // SLAAC when v6 prefix selection is ambiguous. + let mut v6_prefixes = self + .prefixes + .iter() + .filter(|prefix| prefix.prefix.is_ipv6()); + let prefix = &v6_prefixes.next()?.prefix; + if v6_prefixes.next().is_some() || prefix.prefix() != 64 { + return None; + } + + Some(prefix) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] @@ -429,6 +454,7 @@ impl NewNetworkSegment { mod tests { use carbide_test_support::Outcome::*; use carbide_test_support::{scenarios, value_scenarios}; + use carbide_uuid::network::NetworkPrefixId; use super::*; @@ -445,6 +471,86 @@ mod tests { } } + /// Builds a minimal network segment fixture for segment-level predicate tests. + fn test_segment(allocation_strategy: AllocationStrategy, prefixes: &[&str]) -> NetworkSegment { + let segment_id = NetworkSegmentId::new(); + let now = Utc::now(); + + NetworkSegment { + id: segment_id, + version: ConfigVersion::initial(), + config: NetworkSegmentConfig { + name: "test-segment".to_string(), + subdomain_id: None, + mtu: 1500, + segment_type: NetworkSegmentType::Admin, + allocation_strategy, + vpc_id: None, + }, + status: NetworkSegmentStatus { + controller_state: Versioned::new( + NetworkSegmentControllerState::Ready, + ConfigVersion::initial(), + ), + controller_state_outcome: None, + history: Vec::new(), + vlan_id: None, + vni: None, + can_stretch: None, + }, + prefixes: prefixes + .iter() + .map(|prefix| NetworkPrefix { + id: NetworkPrefixId::new(), + segment_id, + prefix: prefix.parse().unwrap(), + gateway: None, + dhcpv6_link_address: None, + num_reserved: 0, + vpc_prefix_id: None, + vpc_prefix: None, + svi_ip: None, + num_free_ips: 0, + }) + .collect(), + created: now, + updated: now, + deleted: None, + } + } + + #[test] + fn slaac_eligible_returns_only_the_single_dynamic_v6_64_prefix() { + // Exercise the segment-only SLAAC chokepoint across the prefix and + // allocation-strategy cases the DHCP handler must not reimplement. + value_scenarios!( + run = |segment: NetworkSegment| segment.slaac_eligible().copied(); + "dynamic segment with one v6 /64" { + test_segment(AllocationStrategy::Dynamic, &["2001:db8::/64"]) => Some("2001:db8::/64".parse().unwrap()), + } + + "dynamic dual-stack segment with one v6 /64" { + test_segment(AllocationStrategy::Dynamic, &["192.0.2.0/24", "2001:db8::/64"]) => Some("2001:db8::/64".parse().unwrap()), + } + + "reserved segment with one v6 /64" { + test_segment(AllocationStrategy::Reserved, &["2001:db8::/64"]) => None, + } + + "dynamic segment with no v6 prefix" { + test_segment(AllocationStrategy::Dynamic, &["192.0.2.0/24"]) => None, + } + + "dynamic segment with non-64 v6 prefix" { + test_segment(AllocationStrategy::Dynamic, &["2001:db8::/80"]) => None, + } + + "dynamic segment with ambiguous v6 prefixes" { + test_segment(AllocationStrategy::Dynamic, &["2001:db8::/64", "2001:db8:1::/80"]) => None, + } + ); + } + #[test] fn controller_state_serializes_to_tagged_json() { scenarios!( diff --git a/crates/dhcp-server/src/main.rs b/crates/dhcp-server/src/main.rs index 693d12807e..a5c75c902e 100644 --- a/crates/dhcp-server/src/main.rs +++ b/crates/dhcp-server/src/main.rs @@ -554,8 +554,6 @@ impl Test { booturl: None, last_invalidation_time: None, ntp_servers: vec!["1.2.3.4".to_string(), "5.6.7.8".to_string()], - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, }) } } diff --git a/crates/dhcp-server/src/modes/dpu.rs b/crates/dhcp-server/src/modes/dpu.rs index 406e931897..2a465927c3 100644 --- a/crates/dhcp-server/src/modes/dpu.rs +++ b/crates/dhcp-server/src/modes/dpu.rs @@ -45,8 +45,6 @@ fn from_host_conf(value: &InterfaceInfo, interface_id: MachineInterfaceId) -> Dh booturl: value.booturl.clone(), last_invalidation_time: None, ntp_servers: vec![], - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, } } diff --git a/crates/dhcp/src/mock_api_server.rs b/crates/dhcp/src/mock_api_server.rs index d2e3e9472d..2c71a3cd54 100644 --- a/crates/dhcp/src/mock_api_server.rs +++ b/crates/dhcp/src/mock_api_server.rs @@ -64,8 +64,6 @@ pub fn base_dhcp_response(mac_address: MacAddress) -> rpc::DhcpRecord { booturl: None, last_invalidation_time: None, ntp_servers: vec!["198.51.100.10".to_string(), "198.51.100.11".to_string()], - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, } } diff --git a/crates/rpc/proto/forge.proto b/crates/rpc/proto/forge.proto index f8289575cf..111908b2fd 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -3963,6 +3963,9 @@ message ExpireDhcpLeaseResponse { } message DhcpRecord { + reserved 14, 15; + reserved "dhcpv6_preferred_lifetime_secs", "dhcpv6_valid_lifetime_secs"; + common.MachineId machine_id = 1; common.MachineInterfaceId machine_interface_id = 2; common.NetworkSegmentId segment_id = 3; @@ -3985,15 +3988,6 @@ message DhcpRecord { // Per site NTP server IPs repeated string ntp_servers = 13; - - // DHCPv6 preferred lifetime in seconds. Set only on IPv6 DHCP responses. - // When present, dhcpv6_valid_lifetime_secs must also be present and must be - // greater than or equal to this value. - optional uint32 dhcpv6_preferred_lifetime_secs = 14; - // DHCPv6 valid lifetime in seconds. Set only on IPv6 DHCP responses. - // When both DHCPv6 lifetime fields are present, this value must be greater - // than or equal to dhcpv6_preferred_lifetime_secs. - optional uint32 dhcpv6_valid_lifetime_secs = 15; } message NetworkSegmentList { diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index faebc742a7..3866e75515 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -1036,34 +1036,4 @@ mod tests { assert_eq!(decoded.message_kind, Some(2)); assert_eq!(decoded.duid, Some(vec![0, 1, 0, 1, 0xaa, 0xbb])); } - - /// Verifies the additive DHCP record IPv6 fields survive protobuf encoding. - #[test] - fn dhcp_record_round_trips_with_ipv6_fields() { - let record = forge::DhcpRecord { - machine_id: None, - machine_interface_id: None, - segment_id: None, - subdomain_id: None, - fqdn: "host.example.com".to_string(), - mac_address: "00:11:22:33:44:55".to_string(), - address: "2001:db8::10".to_string(), - mtu: 9000, - prefix: "2001:db8::/64".to_string(), - gateway: None, - booturl: None, - last_invalidation_time: None, - ntp_servers: vec![], - dhcpv6_preferred_lifetime_secs: Some(3600), - dhcpv6_valid_lifetime_secs: Some(7200), - }; - - // Encode then decode so prost field numbering is exercised directly. - let encoded = record.encode_to_vec(); - let decoded = forge::DhcpRecord::decode(&encoded[..]).unwrap(); - - // Verify the newly-added IPv6 fields survive the protobuf round trip. - assert_eq!(decoded.dhcpv6_preferred_lifetime_secs, Some(3600)); - assert_eq!(decoded.dhcpv6_valid_lifetime_secs, Some(7200)); - } } diff --git a/crates/rpc/src/model/dhcp_record.rs b/crates/rpc/src/model/dhcp_record.rs index 0266a56355..49374a7cb9 100644 --- a/crates/rpc/src/model/dhcp_record.rs +++ b/crates/rpc/src/model/dhcp_record.rs @@ -35,8 +35,6 @@ impl From for rpc::forge::DhcpRecord { booturl: None, // TODO(ajf): extend database, synthesize URL last_invalidation_time: Some(record.last_invalidation_time.into()), ntp_servers: vec![], - dhcpv6_preferred_lifetime_secs: None, - dhcpv6_valid_lifetime_secs: None, } } } diff --git a/rest-api/flow/internal/nicoapi/gen/nico.pb.go b/rest-api/flow/internal/nicoapi/gen/nico.pb.go index f336371917..bf687bcb8f 100644 --- a/rest-api/flow/internal/nicoapi/gen/nico.pb.go +++ b/rest-api/flow/internal/nicoapi/gen/nico.pb.go @@ -22280,17 +22280,9 @@ type DhcpRecord struct { // The last time any underlay or admin DHCP record managed by Forge got invalidated LastInvalidationTime *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=last_invalidation_time,json=lastInvalidationTime,proto3,oneof" json:"last_invalidation_time,omitempty"` // Per site NTP server IPs - NtpServers []string `protobuf:"bytes,13,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"` - // DHCPv6 preferred lifetime in seconds. Set only on IPv6 DHCP responses. - // When present, dhcpv6_valid_lifetime_secs must also be present and must be - // greater than or equal to this value. - Dhcpv6PreferredLifetimeSecs *uint32 `protobuf:"varint,14,opt,name=dhcpv6_preferred_lifetime_secs,json=dhcpv6PreferredLifetimeSecs,proto3,oneof" json:"dhcpv6_preferred_lifetime_secs,omitempty"` - // DHCPv6 valid lifetime in seconds. Set only on IPv6 DHCP responses. - // When both DHCPv6 lifetime fields are present, this value must be greater - // than or equal to dhcpv6_preferred_lifetime_secs. - Dhcpv6ValidLifetimeSecs *uint32 `protobuf:"varint,15,opt,name=dhcpv6_valid_lifetime_secs,json=dhcpv6ValidLifetimeSecs,proto3,oneof" json:"dhcpv6_valid_lifetime_secs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + NtpServers []string `protobuf:"bytes,13,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DhcpRecord) Reset() { @@ -22414,20 +22406,6 @@ func (x *DhcpRecord) GetNtpServers() []string { return nil } -func (x *DhcpRecord) GetDhcpv6PreferredLifetimeSecs() uint32 { - if x != nil && x.Dhcpv6PreferredLifetimeSecs != nil { - return *x.Dhcpv6PreferredLifetimeSecs - } - return 0 -} - -func (x *DhcpRecord) GetDhcpv6ValidLifetimeSecs() uint32 { - if x != nil && x.Dhcpv6ValidLifetimeSecs != nil { - return *x.Dhcpv6ValidLifetimeSecs - } - return 0 -} - type NetworkSegmentList struct { state protoimpl.MessageState `protogen:"open.v1"` NetworkSegments []*NetworkSegment `protobuf:"bytes,1,rep,name=network_segments,json=networkSegments,proto3" json:"network_segments,omitempty"` @@ -61039,7 +61017,7 @@ const file_nico_proto_rawDesc = "" + "\x17ExpireDhcpLeaseResponse\x12\x1d\n" + "\n" + "ip_address\x18\x01 \x01(\tR\tipAddress\x124\n" + - "\x06status\x18\x02 \x01(\x0e2\x1c.forge.ExpireDhcpLeaseStatusR\x06status\"\xaa\x06\n" + + "\x06status\x18\x02 \x01(\x0e2\x1c.forge.ExpireDhcpLeaseStatusR\x06status\"\xa4\x05\n" + "\n" + "DhcpRecord\x120\n" + "\n" + @@ -61059,16 +61037,12 @@ const file_nico_proto_rawDesc = "" + "\abooturl\x18\v \x01(\tH\x01R\abooturl\x88\x01\x01\x12U\n" + "\x16last_invalidation_time\x18\f \x01(\v2\x1a.google.protobuf.TimestampH\x02R\x14lastInvalidationTime\x88\x01\x01\x12\x1f\n" + "\vntp_servers\x18\r \x03(\tR\n" + - "ntpServers\x12H\n" + - "\x1edhcpv6_preferred_lifetime_secs\x18\x0e \x01(\rH\x03R\x1bdhcpv6PreferredLifetimeSecs\x88\x01\x01\x12@\n" + - "\x1adhcpv6_valid_lifetime_secs\x18\x0f \x01(\rH\x04R\x17dhcpv6ValidLifetimeSecs\x88\x01\x01B\n" + + "ntpServersB\n" + "\n" + "\b_gatewayB\n" + "\n" + "\b_booturlB\x19\n" + - "\x17_last_invalidation_timeB!\n" + - "\x1f_dhcpv6_preferred_lifetime_secsB\x1d\n" + - "\x1b_dhcpv6_valid_lifetime_secs\"V\n" + + "\x17_last_invalidation_timeJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10R\x1edhcpv6_preferred_lifetime_secsR\x1adhcpv6_valid_lifetime_secs\"V\n" + "\x12NetworkSegmentList\x12@\n" + "\x10network_segments\x18\x01 \x03(\v2\x15.forge.NetworkSegmentR\x0fnetworkSegments\"E\n" + "\x17SSHKeyValidationRequest\x12\x12\n" + diff --git a/rest-api/flow/internal/nicoapi/nicoproto/nico.proto b/rest-api/flow/internal/nicoapi/nicoproto/nico.proto index 10122a9b4e..592066fa76 100644 --- a/rest-api/flow/internal/nicoapi/nicoproto/nico.proto +++ b/rest-api/flow/internal/nicoapi/nicoproto/nico.proto @@ -3955,6 +3955,9 @@ message ExpireDhcpLeaseResponse { } message DhcpRecord { + reserved 14, 15; + reserved "dhcpv6_preferred_lifetime_secs", "dhcpv6_valid_lifetime_secs"; + common.MachineId machine_id = 1; common.MachineInterfaceId machine_interface_id = 2; common.NetworkSegmentId segment_id = 3; @@ -3977,15 +3980,6 @@ message DhcpRecord { // Per site NTP server IPs repeated string ntp_servers = 13; - - // DHCPv6 preferred lifetime in seconds. Set only on IPv6 DHCP responses. - // When present, dhcpv6_valid_lifetime_secs must also be present and must be - // greater than or equal to this value. - optional uint32 dhcpv6_preferred_lifetime_secs = 14; - // DHCPv6 valid lifetime in seconds. Set only on IPv6 DHCP responses. - // When both DHCPv6 lifetime fields are present, this value must be greater - // than or equal to dhcpv6_preferred_lifetime_secs. - optional uint32 dhcpv6_valid_lifetime_secs = 15; } message NetworkSegmentList { diff --git a/rest-api/sdk/standard/client.go b/rest-api/sdk/standard/client.go index 30b573f2ab..5c91a5ad7c 100644 --- a/rest-api/sdk/standard/client.go +++ b/rest-api/sdk/standard/client.go @@ -596,10 +596,7 @@ func addFile(w *multipart.Writer, fieldName, path string) error { if err != nil { return err } - err = file.Close() - if err != nil { - return err - } + defer file.Close() part, err := w.CreateFormFile(fieldName, filepath.Base(path)) if err != nil { diff --git a/rest-api/sdk/standard/model_batch_instance_create_request.go b/rest-api/sdk/standard/model_batch_instance_create_request.go index 8c67e59d8c..d89ab1cfcd 100644 --- a/rest-api/sdk/standard/model_batch_instance_create_request.go +++ b/rest-api/sdk/standard/model_batch_instance_create_request.go @@ -49,8 +49,9 @@ type BatchInstanceCreateRequest struct { // When set to true, the iPXE script specified by OS or overridden here will always be run when rebooting the Instances. OS must be of iPXE type. AlwaysBootWithCustomIpxe *bool `json:"alwaysBootWithCustomIpxe,omitempty"` // When set to true, the Instances will be enabled with the Phone Home service. - PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` + // Key-value objects to be applied to all instances (shared across all instances) + Labels map[string]string `json:"labels,omitempty"` // Interface configuration shared across all instances. At least one interface must be specified unless `autoNetwork` is true. Either Subnet or VPC Prefix interfaces allowed, only one of the Subnets or VPC Prefixes can be attached over Physical interface. Interface `ipAddress` is not supported for batch instance creation requests. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. Interfaces []InterfaceCreateRequest `json:"interfaces,omitempty"` // When true, asks NICo to auto-resolve each Instance's network interfaces from the host's underlay (HostInband) network segments. Intended for instances on zero-DPU hosts (or hosts with their DPU in NIC mode). When true: (1) the target VPC's `networkVirtualizationType` MUST be `FLAT`, (2) `interfaces` MUST be empty or omitted, and (3) `secondaryVpcIds` MUST be empty or omitted. diff --git a/rest-api/sdk/standard/model_expected_machine.go b/rest-api/sdk/standard/model_expected_machine.go index 2071004b48..806e307ac4 100644 --- a/rest-api/sdk/standard/model_expected_machine.go +++ b/rest-api/sdk/standard/model_expected_machine.go @@ -60,8 +60,9 @@ type ExpectedMachine struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled *bool `json:"isDpfEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled *bool `json:"isDpfEnabled,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Machines + Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Machine was created Created *time.Time `json:"created,omitempty"` // ISO 8601 datetime when the Expected Machine was last updated diff --git a/rest-api/sdk/standard/model_expected_machine_create_request.go b/rest-api/sdk/standard/model_expected_machine_create_request.go index 94ff3179a0..3f08589cb4 100644 --- a/rest-api/sdk/standard/model_expected_machine_create_request.go +++ b/rest-api/sdk/standard/model_expected_machine_create_request.go @@ -57,8 +57,9 @@ type ExpectedMachineCreateRequest struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Machines + Labels map[string]string `json:"labels,omitempty"` } type _ExpectedMachineCreateRequest ExpectedMachineCreateRequest diff --git a/rest-api/sdk/standard/model_expected_machine_update_request.go b/rest-api/sdk/standard/model_expected_machine_update_request.go index 1954cf9757..0102d49e47 100644 --- a/rest-api/sdk/standard/model_expected_machine_update_request.go +++ b/rest-api/sdk/standard/model_expected_machine_update_request.go @@ -55,8 +55,9 @@ type ExpectedMachineUpdateRequest struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Machines + Labels map[string]string `json:"labels,omitempty"` } // NewExpectedMachineUpdateRequest instantiates a new ExpectedMachineUpdateRequest object diff --git a/rest-api/sdk/standard/model_expected_power_shelf.go b/rest-api/sdk/standard/model_expected_power_shelf.go index c0b3af60e7..2d16e542c7 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf.go +++ b/rest-api/sdk/standard/model_expected_power_shelf.go @@ -48,7 +48,8 @@ type ExpectedPowerShelf struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Power Shelves Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Power Shelf was created Created *time.Time `json:"created,omitempty"` diff --git a/rest-api/sdk/standard/model_expected_power_shelf_create_request.go b/rest-api/sdk/standard/model_expected_power_shelf_create_request.go index be4b1a218b..ff60d5aea7 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf_create_request.go +++ b/rest-api/sdk/standard/model_expected_power_shelf_create_request.go @@ -51,7 +51,8 @@ type ExpectedPowerShelfCreateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Power Shelves Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_power_shelf_update_request.go b/rest-api/sdk/standard/model_expected_power_shelf_update_request.go index 8791a51156..20d872f1d0 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf_update_request.go +++ b/rest-api/sdk/standard/model_expected_power_shelf_update_request.go @@ -49,7 +49,8 @@ type ExpectedPowerShelfUpdateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Power Shelves Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_rack.go b/rest-api/sdk/standard/model_expected_rack.go index a18a054327..0ee67b28e7 100644 --- a/rest-api/sdk/standard/model_expected_rack.go +++ b/rest-api/sdk/standard/model_expected_rack.go @@ -34,8 +34,9 @@ type ExpectedRack struct { // Human-readable name of the Expected Rack Name *string `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description *string `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Description *string `json:"description,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. + Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Rack was created Created *time.Time `json:"created,omitempty"` // ISO 8601 datetime when the Expected Rack was last updated diff --git a/rest-api/sdk/standard/model_expected_rack_create_request.go b/rest-api/sdk/standard/model_expected_rack_create_request.go index 120309f55f..88f7b633c4 100644 --- a/rest-api/sdk/standard/model_expected_rack_create_request.go +++ b/rest-api/sdk/standard/model_expected_rack_create_request.go @@ -33,8 +33,9 @@ type ExpectedRackCreateRequest struct { // Human-readable name of the Expected Rack Name NullableString `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description NullableString `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. + Labels map[string]string `json:"labels,omitempty"` } type _ExpectedRackCreateRequest ExpectedRackCreateRequest diff --git a/rest-api/sdk/standard/model_expected_rack_update_request.go b/rest-api/sdk/standard/model_expected_rack_update_request.go index c0c7152fc9..3c6b39f1d0 100644 --- a/rest-api/sdk/standard/model_expected_rack_update_request.go +++ b/rest-api/sdk/standard/model_expected_rack_update_request.go @@ -31,8 +31,9 @@ type ExpectedRackUpdateRequest struct { // Human-readable name of the Expected Rack Name NullableString `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description NullableString `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. + Labels map[string]string `json:"labels,omitempty"` } // NewExpectedRackUpdateRequest instantiates a new ExpectedRackUpdateRequest object diff --git a/rest-api/sdk/standard/model_expected_switch.go b/rest-api/sdk/standard/model_expected_switch.go index 4267e45a2a..813c498b80 100644 --- a/rest-api/sdk/standard/model_expected_switch.go +++ b/rest-api/sdk/standard/model_expected_switch.go @@ -48,7 +48,8 @@ type ExpectedSwitch struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Switches Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Switch was created Created *time.Time `json:"created,omitempty"` diff --git a/rest-api/sdk/standard/model_expected_switch_create_request.go b/rest-api/sdk/standard/model_expected_switch_create_request.go index bc5f5c50ba..fd9a7bbfec 100644 --- a/rest-api/sdk/standard/model_expected_switch_create_request.go +++ b/rest-api/sdk/standard/model_expected_switch_create_request.go @@ -55,7 +55,8 @@ type ExpectedSwitchCreateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Switches Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_switch_update_request.go b/rest-api/sdk/standard/model_expected_switch_update_request.go index 69fa8f13b8..62d39e825d 100644 --- a/rest-api/sdk/standard/model_expected_switch_update_request.go +++ b/rest-api/sdk/standard/model_expected_switch_update_request.go @@ -53,7 +53,8 @@ type ExpectedSwitchUpdateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` + HostId NullableInt32 `json:"hostId,omitempty"` + // User-defined key-value pairs for organizing and categorizing Expected Switches Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_infini_band_partition.go b/rest-api/sdk/standard/model_infini_band_partition.go index 4c174c91e8..3b1711832a 100644 --- a/rest-api/sdk/standard/model_infini_band_partition.go +++ b/rest-api/sdk/standard/model_infini_band_partition.go @@ -46,8 +46,9 @@ type InfiniBandPartition struct { // MTU configured for the InfiniBand Partition Mtu NullableInt32 `json:"mtu,omitempty"` // Whether SHARP is enabled for the InfiniBand Partition - EnableSharp *bool `json:"enableSharp,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + EnableSharp *bool `json:"enableSharp,omitempty"` + // String key-value pairs describing InfiniBand Partition labels. Up to 10 key-value pairs can be specified + Labels map[string]string `json:"labels,omitempty"` // Status of the InfiniBand Partition Status *InfiniBandPartitionStatus `json:"status,omitempty"` // Chronological status history for the InfiniBand Partition diff --git a/rest-api/sdk/standard/model_infini_band_partition_create_request.go b/rest-api/sdk/standard/model_infini_band_partition_create_request.go index f611b52665..0433a75667 100644 --- a/rest-api/sdk/standard/model_infini_band_partition_create_request.go +++ b/rest-api/sdk/standard/model_infini_band_partition_create_request.go @@ -29,7 +29,8 @@ type InfiniBandPartitionCreateRequest struct { // Optional description of the Partition Description NullableString `json:"description,omitempty"` // ID of the Site the Partition should belong to - SiteId string `json:"siteId"` + SiteId string `json:"siteId"` + // String key-value pairs describing Partition labels. Up to 10 key-value pairs can be specified Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_infini_band_partition_update_request.go b/rest-api/sdk/standard/model_infini_band_partition_update_request.go index 49d26ba38c..3f98785f0e 100644 --- a/rest-api/sdk/standard/model_infini_band_partition_update_request.go +++ b/rest-api/sdk/standard/model_infini_band_partition_update_request.go @@ -27,8 +27,9 @@ type InfiniBandPartitionUpdateRequest struct { // Name of the InfiniBand Partition Name string `json:"name"` // Description of the InfiniBand Partition - Description NullableString `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + // String key-value pairs describing Partition labels. Up to 10 key-value pairs can be specified + Labels map[string]string `json:"labels,omitempty"` } type _InfiniBandPartitionUpdateRequest InfiniBandPartitionUpdateRequest diff --git a/rest-api/sdk/standard/model_instance.go b/rest-api/sdk/standard/model_instance.go index da89be33c2..043115b44e 100644 --- a/rest-api/sdk/standard/model_instance.go +++ b/rest-api/sdk/standard/model_instance.go @@ -60,8 +60,9 @@ type Instance struct { // Indicates whether the Phone Home service should be enabled or disabled for the Instance PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` // UserData is inherited from Operating System or specified by user if allowed - UserData NullableString `json:"userData,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + UserData NullableString `json:"userData,omitempty"` + // User-specified Instance labels + Labels map[string]string `json:"labels,omitempty"` // Indicates whether an update is available for the Instance. Updates can be applied on reboot IsUpdatePending *bool `json:"isUpdatePending,omitempty"` // Serial Console URL for the Instance. Format: ssh://@siteSerialConsoleHostname diff --git a/rest-api/sdk/standard/model_instance_create_request.go b/rest-api/sdk/standard/model_instance_create_request.go index dfd872cfff..0b84778049 100644 --- a/rest-api/sdk/standard/model_instance_create_request.go +++ b/rest-api/sdk/standard/model_instance_create_request.go @@ -49,8 +49,9 @@ type InstanceCreateRequest struct { // When set to true, the iPXE script specified by OS or overridden here will always be run when rebooting the Instance. OS must be of iPXE type. AlwaysBootWithCustomIpxe *bool `json:"alwaysBootWithCustomIpxe,omitempty"` // When set to true, the Instance will be enabled with the Phone Home service. - PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` + // User-defined key-value labels + Labels map[string]string `json:"labels,omitempty"` // At least one interface must be specified unless `autoNetwork` is true. Either Subnet or VPC Prefix interfaces allowed. Only one of the Subnets or VPC Prefixes can be attached over Physical interface. If only one Subnet is specified, then it will be attached over physical interface regardless of the value of isPhysical. In case of VPC Prefix, isPhysical will always be true. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. Interfaces []InterfaceCreateRequest `json:"interfaces,omitempty"` // When true, asks NICo to auto-resolve the Instance's network interfaces from the host's underlay (HostInband) network segments. Intended for instances on zero-DPU hosts (or hosts with their DPU in NIC mode). When true: (1) the target VPC's `networkVirtualizationType` MUST be `FLAT`, (2) `interfaces` MUST be empty or omitted, and (3) `secondaryVpcIds` MUST be empty or omitted. Resolved interfaces surface on the Instance's read response. diff --git a/rest-api/sdk/standard/model_instance_type.go b/rest-api/sdk/standard/model_instance_type.go index cb0d2726ed..f597262024 100644 --- a/rest-api/sdk/standard/model_instance_type.go +++ b/rest-api/sdk/standard/model_instance_type.go @@ -34,7 +34,8 @@ type InstanceType struct { // ID of the Infrastructure Provider that owns the Instance Type InfrastructureProviderId *string `json:"infrastructureProviderId,omitempty"` // ID of the Site that owns the Instance Type - SiteId *string `json:"siteId,omitempty"` + SiteId *string `json:"siteId,omitempty"` + // User-defined key-value labels for the Instance Type Labels map[string]string `json:"labels,omitempty"` // List of capabilities that are supported by the Machine's of this Instance Type MachineCapabilities []MachineCapability `json:"machineCapabilities,omitempty"` diff --git a/rest-api/sdk/standard/model_instance_type_create_request.go b/rest-api/sdk/standard/model_instance_type_create_request.go index 3898057f9f..45f550ae32 100644 --- a/rest-api/sdk/standard/model_instance_type_create_request.go +++ b/rest-api/sdk/standard/model_instance_type_create_request.go @@ -29,7 +29,8 @@ type InstanceTypeCreateRequest struct { // Description of the Instance Type Description NullableString `json:"description,omitempty"` // ID of the site - SiteId string `json:"siteId"` + SiteId string `json:"siteId"` + // User-defined key-value labels for the Instance Type Labels map[string]string `json:"labels,omitempty"` // Site Controller assigned Machine type ControllerMachineType NullableString `json:"controllerMachineType,omitempty"` diff --git a/rest-api/sdk/standard/model_instance_type_update_request.go b/rest-api/sdk/standard/model_instance_type_update_request.go index 21ccc2cb23..a7b93a2e8a 100644 --- a/rest-api/sdk/standard/model_instance_type_update_request.go +++ b/rest-api/sdk/standard/model_instance_type_update_request.go @@ -25,8 +25,9 @@ type InstanceTypeUpdateRequest struct { // Name of the Instance Type Name NullableString `json:"name,omitempty"` // Description of the Instance Type - Description NullableString `json:"description,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + // User-defined key-value labels for the Instance Type + Labels map[string]string `json:"labels,omitempty"` // List of Machine Capabilities to match MachineCapabilities []MachineCapability `json:"machineCapabilities,omitempty"` } diff --git a/rest-api/sdk/standard/model_instance_update_request.go b/rest-api/sdk/standard/model_instance_update_request.go index b3825b8877..11b8ebc57d 100644 --- a/rest-api/sdk/standard/model_instance_update_request.go +++ b/rest-api/sdk/standard/model_instance_update_request.go @@ -45,8 +45,9 @@ type InstanceUpdateRequest struct { // Whether the custom iPXE data should be used for every boot. AlwaysBootWithCustomIpxe NullableBool `json:"alwaysBootWithCustomIpxe,omitempty"` // Indicates whether the Phone Home service should be enabled or disabled for the Instance - PhoneHomeEnabled NullableBool `json:"phoneHomeEnabled,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled NullableBool `json:"phoneHomeEnabled,omitempty"` + // Update labels of the Instance. The labels will be replaced with the labels sent in the request. Any labels not included in the request will be removed. To retain existing labels, fetch them first and include them in this request. + Labels map[string]string `json:"labels,omitempty"` // IDs of additional VPCs the Instance should attach to through non-primary interfaces. This field may only be specified when every entry in `interfaces` uses `vpcPrefixId`. IDs must be unique, must be valid UUIDs, and must not include the primary `vpcId`. SecondaryVpcIds []string `json:"secondaryVpcIds,omitempty"` // Update Interfaces of the Instance. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. diff --git a/rest-api/sdk/standard/model_interface.go b/rest-api/sdk/standard/model_interface.go index 3f16367db1..584892c0c3 100644 --- a/rest-api/sdk/standard/model_interface.go +++ b/rest-api/sdk/standard/model_interface.go @@ -44,7 +44,8 @@ type Interface struct { // A list of IPv4 or IPv6 addresses IpAddresses []string `json:"ipAddresses,omitempty"` // Explicitly requested IP address for the interface. This is only used for VPC Prefix-based interfaces and is not valid for Subnet-based interfaces. The least-significant host bit must be 1. - RequestedIpAddress NullableString `json:"requestedIpAddress,omitempty"` + RequestedIpAddress NullableString `json:"requestedIpAddress,omitempty"` + // Inline interface-local routing profile options. Only valid for VPC Prefix-based interfaces. InlineRoutingProfile NullableInterfaceInlineRoutingProfile `json:"inlineRoutingProfile,omitempty"` // Status of the Interface Status *InterfaceStatus `json:"status,omitempty"` diff --git a/rest-api/sdk/standard/model_interface_create_request.go b/rest-api/sdk/standard/model_interface_create_request.go index 5b5bdb37a1..8aa1eb7e3b 100644 --- a/rest-api/sdk/standard/model_interface_create_request.go +++ b/rest-api/sdk/standard/model_interface_create_request.go @@ -27,7 +27,8 @@ type InterfaceCreateRequest struct { // ID of the VPC Prefix to attach to the Interface VpcPrefixId *string `json:"vpcPrefixId,omitempty"` // Explicitly requested IP address for the interface. It cannot be specified for Subnet-based interfaces. The least-significant host bit must be 1. - IpAddress NullableString `json:"ipAddress,omitempty"` + IpAddress NullableString `json:"ipAddress,omitempty"` + // Inline interface-local routing profile options. It cannot be specified for Subnet-based interfaces. InlineRoutingProfile NullableInterfaceInlineRoutingProfile `json:"inlineRoutingProfile,omitempty"` // Specifies whether this Subnet or VPC Prefix should be attached to the Instance over physical interface. IsPhysical *bool `json:"isPhysical,omitempty"` diff --git a/rest-api/sdk/standard/model_machine.go b/rest-api/sdk/standard/model_machine.go index 4536a96891..11894772ca 100644 --- a/rest-api/sdk/standard/model_machine.go +++ b/rest-api/sdk/standard/model_machine.go @@ -60,8 +60,9 @@ type Machine struct { // Health information about the machine Health *MachineHealth `json:"health,omitempty"` // Only available to Providers. Returned if the `includeMetadata` query parameter is specified. Otherwise attribute is omitted from response. - Metadata *MachineMetadata `json:"metadata,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Metadata *MachineMetadata `json:"metadata,omitempty"` + // User-specified Machine labels + Labels map[string]string `json:"labels,omitempty"` // Status represents the status of the machine Status *MachineStatus `json:"status,omitempty"` // Indicates whether the machine is usable by or currently in use by a tenant. diff --git a/rest-api/sdk/standard/model_machine_update_request.go b/rest-api/sdk/standard/model_machine_update_request.go index f0afefcad5..b81522ad74 100644 --- a/rest-api/sdk/standard/model_machine_update_request.go +++ b/rest-api/sdk/standard/model_machine_update_request.go @@ -29,8 +29,9 @@ type MachineUpdateRequest struct { // Set to `true` to enable maintenance mode and to `false` to disable maintenance mode. Can be set by Provider or Privileged Tenant. SetMaintenanceMode NullableBool `json:"setMaintenanceMode,omitempty"` // Optional message describing the reason for moving Machine into maintenance mode. Can be updated by Provider or Privileged Tenant. - MaintenanceMessage NullableString `json:"maintenanceMessage,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + MaintenanceMessage NullableString `json:"maintenanceMessage,omitempty"` + // Machine labels will be overwritten, include existing labels to preserve them. Can be updated by Provider or Privileged Tenant. + Labels map[string]string `json:"labels,omitempty"` // Request to enter/exit online repair OnlineRepair *MachineOnlineRepair `json:"onlineRepair,omitempty"` // Required when `onlineRepair.enabled` is true. Must not be set when exiting online repair (`onlineRepair.enabled` false). diff --git a/rest-api/sdk/standard/model_network_security_group.go b/rest-api/sdk/standard/model_network_security_group.go index 2daa42415a..1c379d94aa 100644 --- a/rest-api/sdk/standard/model_network_security_group.go +++ b/rest-api/sdk/standard/model_network_security_group.go @@ -45,7 +45,8 @@ type NetworkSecurityGroup struct { RuleCount *int32 `json:"ruleCount,omitempty"` // Attachment statistics for the Network Security Group. Returned when the `includeAttachmentStats` query parameter is set to true in retrieval endpoints. AttachmentStats *NetworkSecurityGroupStats `json:"attachmentStats,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + // Set of labels/tags for the Network Security Group + Labels map[string]string `json:"labels,omitempty"` // Date/time when the Network Security Group was created Created *time.Time `json:"created,omitempty"` // Date/time when the Network Security Group was last updated diff --git a/rest-api/sdk/standard/model_network_security_group_create_request.go b/rest-api/sdk/standard/model_network_security_group_create_request.go index 5bca071db2..adea432519 100644 --- a/rest-api/sdk/standard/model_network_security_group_create_request.go +++ b/rest-api/sdk/standard/model_network_security_group_create_request.go @@ -35,8 +35,9 @@ type NetworkSecurityGroupCreateRequest struct { // Egress rules with protocol and destination ports defined but without source ports defined should automatically be made stateful. StatefulEgress *bool `json:"statefulEgress,omitempty"` // Rules that belong to the Network Security Group - Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` + // User-defined key-value labels for the Network Security Group + Labels map[string]string `json:"labels,omitempty"` } type _NetworkSecurityGroupCreateRequest NetworkSecurityGroupCreateRequest diff --git a/rest-api/sdk/standard/model_network_security_group_update_request.go b/rest-api/sdk/standard/model_network_security_group_update_request.go index 7a15676b62..1356c8edad 100644 --- a/rest-api/sdk/standard/model_network_security_group_update_request.go +++ b/rest-api/sdk/standard/model_network_security_group_update_request.go @@ -29,8 +29,9 @@ type NetworkSecurityGroupUpdateRequest struct { // Egress rules with protocol and destination ports defined but without source ports defined should automatically be made stateful. StatefulEgress *bool `json:"statefulEgress,omitempty"` // Update rules of the Network Security Group. The rules will be replaced with the rules sent in the request. Any rules not included in the request will be removed. To retain existing rules, fetch them first and include them. - Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` + // User-defined key-value labels for the Network Security Group + Labels map[string]string `json:"labels,omitempty"` } // NewNetworkSecurityGroupUpdateRequest instantiates a new NetworkSecurityGroupUpdateRequest object diff --git a/rest-api/sdk/standard/model_vpc.go b/rest-api/sdk/standard/model_vpc.go index 63828b7f61..fd4a800b6a 100644 --- a/rest-api/sdk/standard/model_vpc.go +++ b/rest-api/sdk/standard/model_vpc.go @@ -50,8 +50,9 @@ type VPC struct { // Propagation details for the attached Network Security Group NetworkSecurityGroupPropagationDetails *NetworkSecurityGroupPropagationDetails `json:"networkSecurityGroupPropagationDetails,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + // String key-value pairs describing VPC labels + Labels map[string]string `json:"labels,omitempty"` // Status of the VPC Status *VpcStatus `json:"status,omitempty"` // History of status changes for the VPC diff --git a/rest-api/sdk/standard/model_vpc_create_request.go b/rest-api/sdk/standard/model_vpc_create_request.go index 1ab9600cf6..0d8fb5a6d6 100644 --- a/rest-api/sdk/standard/model_vpc_create_request.go +++ b/rest-api/sdk/standard/model_vpc_create_request.go @@ -41,8 +41,9 @@ type VpcCreateRequest struct { // Explicitly requested VNI for the VPC Vni NullableInt32 `json:"vni,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + // String key-value pairs describing VPC labels. Up to 10 key-value pairs can be specified + Labels map[string]string `json:"labels,omitempty"` } type _VpcCreateRequest VpcCreateRequest diff --git a/rest-api/sdk/standard/model_vpc_update_request.go b/rest-api/sdk/standard/model_vpc_update_request.go index 4ff6e847f2..cc798e4efb 100644 --- a/rest-api/sdk/standard/model_vpc_update_request.go +++ b/rest-api/sdk/standard/model_vpc_update_request.go @@ -29,8 +29,9 @@ type VpcUpdateRequest struct { // ID of the Network Security Group to attach to the VPC NetworkSecurityGroupId NullableString `json:"networkSecurityGroupId,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to. Can only be updated if VPC currently has no active Instances - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + // Update labels of the VPC. Up to 10 key-value pairs can be specified. The labels will be replaced with the labels sent in the request. Any labels not included in the request will be removed. To retain existing labels, fetch them first and include them in this request. + Labels map[string]string `json:"labels,omitempty"` } // NewVpcUpdateRequest instantiates a new VpcUpdateRequest object diff --git a/rest-api/workflow-schema/schema/site-agent/workflows/v1/nico_nico.pb.go b/rest-api/workflow-schema/schema/site-agent/workflows/v1/nico_nico.pb.go index 00abed3fcf..880cadad0f 100644 --- a/rest-api/workflow-schema/schema/site-agent/workflows/v1/nico_nico.pb.go +++ b/rest-api/workflow-schema/schema/site-agent/workflows/v1/nico_nico.pb.go @@ -22211,17 +22211,9 @@ type DhcpRecord struct { // The last time any underlay or admin DHCP record managed by NICo got invalidated LastInvalidationTime *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=last_invalidation_time,json=lastInvalidationTime,proto3,oneof" json:"last_invalidation_time,omitempty"` // Per site NTP server IPs - NtpServers []string `protobuf:"bytes,13,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"` - // DHCPv6 preferred lifetime in seconds. Set only on IPv6 DHCP responses. - // When present, dhcpv6_valid_lifetime_secs must also be present and must be - // greater than or equal to this value. - Dhcpv6PreferredLifetimeSecs *uint32 `protobuf:"varint,14,opt,name=dhcpv6_preferred_lifetime_secs,json=dhcpv6PreferredLifetimeSecs,proto3,oneof" json:"dhcpv6_preferred_lifetime_secs,omitempty"` - // DHCPv6 valid lifetime in seconds. Set only on IPv6 DHCP responses. - // When both DHCPv6 lifetime fields are present, this value must be greater - // than or equal to dhcpv6_preferred_lifetime_secs. - Dhcpv6ValidLifetimeSecs *uint32 `protobuf:"varint,15,opt,name=dhcpv6_valid_lifetime_secs,json=dhcpv6ValidLifetimeSecs,proto3,oneof" json:"dhcpv6_valid_lifetime_secs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + NtpServers []string `protobuf:"bytes,13,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DhcpRecord) Reset() { @@ -22345,20 +22337,6 @@ func (x *DhcpRecord) GetNtpServers() []string { return nil } -func (x *DhcpRecord) GetDhcpv6PreferredLifetimeSecs() uint32 { - if x != nil && x.Dhcpv6PreferredLifetimeSecs != nil { - return *x.Dhcpv6PreferredLifetimeSecs - } - return 0 -} - -func (x *DhcpRecord) GetDhcpv6ValidLifetimeSecs() uint32 { - if x != nil && x.Dhcpv6ValidLifetimeSecs != nil { - return *x.Dhcpv6ValidLifetimeSecs - } - return 0 -} - type NetworkSegmentList struct { state protoimpl.MessageState `protogen:"open.v1"` NetworkSegments []*NetworkSegment `protobuf:"bytes,1,rep,name=network_segments,json=networkSegments,proto3" json:"network_segments,omitempty"` @@ -61092,7 +61070,7 @@ const file_nico_nico_proto_rawDesc = "" + "\x17ExpireDhcpLeaseResponse\x12\x1d\n" + "\n" + "ip_address\x18\x01 \x01(\tR\tipAddress\x124\n" + - "\x06status\x18\x02 \x01(\x0e2\x1c.forge.ExpireDhcpLeaseStatusR\x06status\"\xaa\x06\n" + + "\x06status\x18\x02 \x01(\x0e2\x1c.forge.ExpireDhcpLeaseStatusR\x06status\"\xa4\x05\n" + "\n" + "DhcpRecord\x120\n" + "\n" + @@ -61112,16 +61090,12 @@ const file_nico_nico_proto_rawDesc = "" + "\abooturl\x18\v \x01(\tH\x01R\abooturl\x88\x01\x01\x12U\n" + "\x16last_invalidation_time\x18\f \x01(\v2\x1a.google.protobuf.TimestampH\x02R\x14lastInvalidationTime\x88\x01\x01\x12\x1f\n" + "\vntp_servers\x18\r \x03(\tR\n" + - "ntpServers\x12H\n" + - "\x1edhcpv6_preferred_lifetime_secs\x18\x0e \x01(\rH\x03R\x1bdhcpv6PreferredLifetimeSecs\x88\x01\x01\x12@\n" + - "\x1adhcpv6_valid_lifetime_secs\x18\x0f \x01(\rH\x04R\x17dhcpv6ValidLifetimeSecs\x88\x01\x01B\n" + + "ntpServersB\n" + "\n" + "\b_gatewayB\n" + "\n" + "\b_booturlB\x19\n" + - "\x17_last_invalidation_timeB!\n" + - "\x1f_dhcpv6_preferred_lifetime_secsB\x1d\n" + - "\x1b_dhcpv6_valid_lifetime_secs\"V\n" + + "\x17_last_invalidation_timeJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10R\x1edhcpv6_preferred_lifetime_secsR\x1adhcpv6_valid_lifetime_secs\"V\n" + "\x12NetworkSegmentList\x12@\n" + "\x10network_segments\x18\x01 \x03(\v2\x15.forge.NetworkSegmentR\x0fnetworkSegments\"E\n" + "\x17SSHKeyValidationRequest\x12\x12\n" + diff --git a/rest-api/workflow-schema/site-agent/workflows/v1/nico_nico.proto b/rest-api/workflow-schema/site-agent/workflows/v1/nico_nico.proto index cd0e8db195..7a1b085635 100644 --- a/rest-api/workflow-schema/site-agent/workflows/v1/nico_nico.proto +++ b/rest-api/workflow-schema/site-agent/workflows/v1/nico_nico.proto @@ -3963,6 +3963,9 @@ message ExpireDhcpLeaseResponse { } message DhcpRecord { + reserved 14, 15; + reserved "dhcpv6_preferred_lifetime_secs", "dhcpv6_valid_lifetime_secs"; + common.MachineId machine_id = 1; common.MachineInterfaceId machine_interface_id = 2; common.NetworkSegmentId segment_id = 3; @@ -3985,15 +3988,6 @@ message DhcpRecord { // Per site NTP server IPs repeated string ntp_servers = 13; - - // DHCPv6 preferred lifetime in seconds. Set only on IPv6 DHCP responses. - // When present, dhcpv6_valid_lifetime_secs must also be present and must be - // greater than or equal to this value. - optional uint32 dhcpv6_preferred_lifetime_secs = 14; - // DHCPv6 valid lifetime in seconds. Set only on IPv6 DHCP responses. - // When both DHCPv6 lifetime fields are present, this value must be greater - // than or equal to dhcpv6_preferred_lifetime_secs. - optional uint32 dhcpv6_valid_lifetime_secs = 15; } message NetworkSegmentList {