From 2c8b5688e270ae91ab4a251d39e04f2ce9709143 Mon Sep 17 00:00:00 2001 From: brian Date: Tue, 14 Jul 2026 18:19:20 -0400 Subject: [PATCH] feat(dhcp): implemented DHCPv6 Kea callouts --- Makefile.toml | 2 +- crates/dhcp/Cargo.toml | 2 +- crates/dhcp/examples/kea-dhcp6-carbide.conf | 65 ++ crates/dhcp/src/cache.rs | 377 ++++++- crates/dhcp/src/discovery.rs | 4 + crates/dhcp/src/discovery_v6.rs | 924 +++++++++++++++++ crates/dhcp/src/kea/callouts.cc | 676 +++++++++++- crates/dhcp/src/kea/callouts.h | 8 + crates/dhcp/src/kea/loader.cc | 318 +++--- crates/dhcp/src/lease_expiration.rs | 6 +- crates/dhcp/src/lib.rs | 337 +++++- crates/dhcp/src/machine.rs | 299 +++++- crates/dhcp/src/machine_v6.rs | 612 +++++++++++ crates/dhcp/src/metrics.rs | 341 +++++- crates/dhcp/src/mock_api_server.rs | 232 ++++- crates/dhcp/tests/common/dhcpv6_factory.rs | 676 ++++++++++++ crates/dhcp/tests/common/kea_v6.rs | 595 +++++++++++ crates/dhcp/tests/common/mod.rs | 139 +++ crates/dhcp/tests/identity_v6_test.rs | 302 ++++++ crates/dhcp/tests/info_request_v6_test.rs | 229 +++++ crates/dhcp/tests/lease6_hook_test.rs | 967 ++++++++++++++++++ crates/dhcp/tests/rapid_commit_v6_test.rs | 53 + .../Dockerfile.runtime-container.localdev | 3 +- dev/docker/Dockerfile.build-container-aarch64 | 1 + dev/docker/Dockerfile.build-container-x86_64 | 1 + dev/docker/Dockerfile.pxe-build-container | 1 + .../Dockerfile.release-container-aarch64 | 2 + .../Dockerfile.release-container-sa-x86_64 | 2 + .../Dockerfile.release-container-x86_64 | 2 + .../Dockerfile.runtime-container-aarch64 | 2 +- .../Dockerfile.runtime-container-x86_64 | 3 +- 31 files changed, 6986 insertions(+), 195 deletions(-) create mode 100644 crates/dhcp/examples/kea-dhcp6-carbide.conf create mode 100644 crates/dhcp/src/discovery_v6.rs create mode 100644 crates/dhcp/src/machine_v6.rs create mode 100644 crates/dhcp/tests/common/dhcpv6_factory.rs create mode 100644 crates/dhcp/tests/common/kea_v6.rs create mode 100644 crates/dhcp/tests/identity_v6_test.rs create mode 100644 crates/dhcp/tests/info_request_v6_test.rs create mode 100644 crates/dhcp/tests/lease6_hook_test.rs create mode 100644 crates/dhcp/tests/rapid_commit_v6_test.rs diff --git a/Makefile.toml b/Makefile.toml index 4e9674dce7..8b59944bb1 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -827,7 +827,7 @@ script = ["cargo xtask check-isolated-package-builds"] workspace = false description = "Check cargo-deny against licenses we're using to validate that we're not introducing new licenses inadvertently" category = "Licensing" -script = ["cargo deny check license"] +script = ["cargo deny check licenses"] [tasks.check-bans] workspace = false diff --git a/crates/dhcp/Cargo.toml b/crates/dhcp/Cargo.toml index 0e4de47747..c51382da72 100644 --- a/crates/dhcp/Cargo.toml +++ b/crates/dhcp/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] chrono = { workspace = true } derive_builder = { workspace = true } +dhcproto = { workspace = true } eyre = { workspace = true } hyper = { workspace = true, features = ["http2"] } hyper-util = { workspace = true, features = ["tokio"] } @@ -37,7 +38,6 @@ metrics-endpoint = { path = "../metrics-endpoint" } [dev-dependencies] eyre = { workspace = true } -dhcproto = { workspace = true } serde_json = { workspace = true } test-cdylib = { workspace = true } tempfile = { workspace = true } diff --git a/crates/dhcp/examples/kea-dhcp6-carbide.conf b/crates/dhcp/examples/kea-dhcp6-carbide.conf new file mode 100644 index 0000000000..06820583f9 --- /dev/null +++ b/crates/dhcp/examples/kea-dhcp6-carbide.conf @@ -0,0 +1,65 @@ +{ + "Dhcp6": { + // Add names of your network interfaces to listen on. + "interfaces-config": { + "interfaces": [ "eth0" ] + }, + + "lease-database": { + "type": "memfile", + "name": "/var/lib/kea/kea-leases6.csv", + "lfc-interval": 3600 + }, + + // IA_NA lifetimes are Kea-native, like kea-dhcp4.conf. + "preferred-lifetime": 3600, + "valid-lifetime": 7200, + "renew-timer": 1800, + "rebind-timer": 2880, + "decline-probation-period": 900, + + "hooks-libraries": [ + { + "library": "/usr/lib/kea/hooks/libdhcp.so", + "parameters": { + "carbide-api-url": "https://carbide-api:1079", + "carbide-metrics-endpoint": "[::]:1090", + "hook-dns-servers-ipv6": "2606:4700:4700::1111", + "hook-ntp-servers-ipv6": "", + "hook-provisioning-server-ipv6": "", + "hook-rapid-commit-v6": false + } + } + ], + + "subnet6": [ + { + "subnet": "::/0", + "pools": [{ + "pool": "2001:db8::1-2001:db8::ffff" + }] + } + ], + + "loggers": [ + { + "name": "kea-dhcp6", + "output_options": [{"output": "stdout"}], + "severity": "DEBUG", + "debuglevel": 10 + }, + { + "name": "kea-dhcp6.carbide-rust", + "output_options": [{"output": "stdout"}], + "severity": "INFO", + "debuglevel": 10 + }, + { + "name": "kea-dhcp6.carbide-callouts", + "output_options": [{"output": "stdout"}], + "severity": "INFO", + "debuglevel": 10 + } + ] + } +} diff --git a/crates/dhcp/src/cache.rs b/crates/dhcp/src/cache.rs index 7eb2a44fd6..6b55853d1a 100644 --- a/crates/dhcp/src/cache.rs +++ b/crates/dhcp/src/cache.rs @@ -23,7 +23,7 @@ /// /// The cache is a static because we are called from Kea's hooks, potentially from multiple threads. use std::{ - net::IpAddr, + net::{IpAddr, Ipv6Addr}, sync::Mutex, time::{Duration, Instant}, }; @@ -31,6 +31,7 @@ use std::{ use lazy_static::lazy_static; use lru::LruCache; use mac_address::MacAddress; +use rpc::forge as rpc; use crate::machine::Machine; @@ -49,6 +50,9 @@ lazy_static! { static ref MACHINE_CACHE: Mutex> = Mutex::new(LruCache::new( std::num::NonZeroUsize::new(MACHINE_CACHE_SIZE).unwrap() )); + static ref INVALIDATED_V6_LEASES: Mutex> = Mutex::new(LruCache::new( + std::num::NonZeroUsize::new(MACHINE_CACHE_SIZE).unwrap() + )); } #[derive(Debug, Clone)] @@ -64,6 +68,19 @@ pub enum CacheEntryStatus { DiscoveryFailed, } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum CacheClass { + Lease, + OptionsOnly, +} + +/// Cache namespace for entries that share identity fields but differ by protocol behavior. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct CacheScope { + pub address_family: rpc::AddressFamily, + pub cache_class: CacheClass, +} + /// Fetch an entry from the cache. /// /// Result is owned by the caller, it is a clone of cached item. @@ -71,13 +88,43 @@ pub enum CacheEntryStatus { /// Returns None if we don't have that item in cache, or if we did but /// it's no longer valid (e.g. too old). pub fn get( + address_family: rpc::AddressFamily, + mac_address: MacAddress, + link_address: IpAddr, + circuit_id: &Option, + remote_id: &Option, + vendor_id: &str, +) -> Option { + get_classed( + address_family, + CacheClass::Lease, + mac_address, + link_address, + circuit_id, + remote_id, + vendor_id, + ) +} + +/// Fetch an entry from the cache with a protocol-specific cache class. +pub fn get_classed( + address_family: rpc::AddressFamily, + cache_class: CacheClass, mac_address: MacAddress, link_address: IpAddr, circuit_id: &Option, remote_id: &Option, vendor_id: &str, ) -> Option { - let key = &key(mac_address, link_address, circuit_id, remote_id, vendor_id); + let key = &key( + address_family, + cache_class, + mac_address, + link_address, + circuit_id, + remote_id, + vendor_id, + ); if key.len() < MIN_KEY_LEN { log::debug!("Unexpected cache key, skipping: '{key}'"); return None; @@ -85,6 +132,18 @@ pub fn get( let mut cache = MACHINE_CACHE.lock().unwrap(); if let Some(entry) = cache.get(key) { if !entry.has_expired() { + // Lease expiry tombstones are stronger than the normal cache TTL: + // stale API responses must not be reused after reclaim starts. + if address_family == rpc::AddressFamily::V6 + && cache_class == CacheClass::Lease + && matches_invalidated_v6_lease(&entry.status, mac_address) + { + log::warn!( + "removed cached DHCPv6 response for recently expired lease: mac={mac_address}" + ); + let _removed = cache.pop_entry(key); + return None; + } return Some(entry.clone()); } else { log::debug!("removed expired cached response for {mac_address:?}"); @@ -94,8 +153,55 @@ pub fn get( None } +/// Fetch non-expired entries matching all key fields except vendor class. +/// +/// This also performs opportunistic cache cleanup for expired or invalidated +/// entries found while scanning matching vendor variants. +pub fn get_classed_any_vendor( + address_family: rpc::AddressFamily, + cache_class: CacheClass, + mac_address: MacAddress, + link_address: IpAddr, + circuit_id: &Option, + remote_id: &Option, +) -> Vec { + let prefix = key_prefix( + address_family, + cache_class, + mac_address, + link_address, + circuit_id, + remote_id, + ); + let mut expired = Vec::new(); + let mut matches = Vec::new(); + let mut cache = MACHINE_CACHE.lock().unwrap(); + for (key, entry) in cache.iter() { + if !key.starts_with(&prefix) { + continue; + } + // CONFIRM may search across vendor variants; keep the expiry tombstone + // semantics identical to the exact-key path. + let invalidated_v6_lease = address_family == rpc::AddressFamily::V6 + && cache_class == CacheClass::Lease + && matches_invalidated_v6_lease(&entry.status, mac_address); + if entry.has_expired() || invalidated_v6_lease { + expired.push(key.clone()); + } else { + matches.push(entry.clone()); + } + } + // Remove stale matches after iteration so the LRU iterator is not mutated + // while it is still borrowed. + for key in expired { + let _removed = cache.pop_entry(&key); + } + matches +} + /// Insert or update an item in the cache pub fn put( + address_family: rpc::AddressFamily, mac_address: MacAddress, link_address: IpAddr, // relay address circuit_id: Option, // vlan id @@ -103,7 +209,46 @@ pub fn put( vendor_id: &str, status: CacheEntryStatus, ) { + put_classed( + CacheScope { + address_family, + cache_class: CacheClass::Lease, + }, + mac_address, + link_address, + circuit_id, + remote_id, + vendor_id, + status, + ); +} + +/// Insert or update an item in the cache with a protocol-specific cache class. +pub fn put_classed( + scope: CacheScope, + mac_address: MacAddress, + link_address: IpAddr, + circuit_id: Option, + remote_id: Option, + vendor_id: &str, + status: CacheEntryStatus, +) { + let CacheScope { + address_family, + cache_class, + } = scope; + + if address_family == rpc::AddressFamily::V6 + && cache_class == CacheClass::Lease + && matches_invalidated_v6_lease(&status, mac_address) + { + log::warn!("not caching DHCPv6 response for recently expired lease: mac={mac_address}"); + return; + } + let key = key( + address_family, + cache_class, mac_address, link_address, &circuit_id, @@ -117,12 +262,95 @@ pub fn put( MACHINE_CACHE.lock().unwrap().put(key, new_entry); } +/// Mark and remove cached DHCPv6 lease entries matching an expired API allocation. +pub fn invalidate_v6_lease(address: Ipv6Addr, mac_address: MacAddress) -> usize { + INVALIDATED_V6_LEASES.lock().unwrap().put( + invalidated_v6_lease_key(address, mac_address), + Instant::now(), + ); + + let key_prefix = format!( + "{:?}_{:?}_{}_", + rpc::AddressFamily::V6, + CacheClass::Lease, + mac_address + ); + let mut matched_keys = Vec::new(); + let mut cache = MACHINE_CACHE.lock().unwrap(); + for (key, entry) in cache.iter() { + if !key.starts_with(&key_prefix) { + continue; + } + + // Expiry is scoped to the API-owned address and hook-selected MAC. + if let CacheEntryStatus::ValidEntry(machine) = &entry.status + && machine.discovery_info.mac_address == mac_address + && machine.inner.address.parse::() == Ok(IpAddr::V6(address)) + { + matched_keys.push(key.clone()); + } + } + + let removed = matched_keys.len(); + for key in matched_keys { + let _removed = cache.pop_entry(&key); + } + removed +} + +/// Clear the recent-expiry tombstone for a DHCPv6 lease. +pub fn clear_v6_lease_invalidation(address: Ipv6Addr, mac_address: MacAddress) -> bool { + INVALIDATED_V6_LEASES + .lock() + .unwrap() + .pop_entry(&invalidated_v6_lease_key(address, mac_address)) + .is_some() +} + +/// Return whether a Machine points at a recently expired DHCPv6 lease. +pub fn machine_matches_invalidated_v6_lease(machine: &Machine) -> bool { + match machine.inner.address.parse::() { + Ok(IpAddr::V6(address)) => { + is_v6_lease_invalidated(address, machine.discovery_info.mac_address) + } + Ok(IpAddr::V4(_)) | Err(_) => false, + } +} + // // Internals // +fn matches_invalidated_v6_lease(status: &CacheEntryStatus, mac_address: MacAddress) -> bool { + match status { + CacheEntryStatus::ValidEntry(machine) => match machine.inner.address.parse::() { + Ok(IpAddr::V6(address)) => is_v6_lease_invalidated(address, mac_address), + Ok(IpAddr::V4(_)) | Err(_) => false, + }, + CacheEntryStatus::DiscoveryFailing(_) | CacheEntryStatus::DiscoveryFailed => false, + } +} + +fn is_v6_lease_invalidated(address: Ipv6Addr, mac_address: MacAddress) -> bool { + let key = invalidated_v6_lease_key(address, mac_address); + let mut invalidated = INVALIDATED_V6_LEASES.lock().unwrap(); + if let Some(timestamp) = invalidated.get(&key) + && timestamp.elapsed() < MACHINE_CACHE_TIMEOUT + { + return true; + } + let _removed = invalidated.pop_entry(&key); + false +} + +fn invalidated_v6_lease_key(address: Ipv6Addr, mac_address: MacAddress) -> String { + format!("{mac_address}_{address}") +} + // Unique identifier for this entry fn key( + address_family: rpc::AddressFamily, + cache_class: CacheClass, mac_address: MacAddress, link_address: IpAddr, circuit_id: &Option, @@ -130,7 +358,31 @@ fn key( vendor_id: &str, ) -> String { format!( - "{}_{}_{}_{}_{}", + "{}{}", + key_prefix( + address_family, + cache_class, + mac_address, + link_address, + circuit_id, + remote_id, + ), + vendor_id, + ) +} + +fn key_prefix( + address_family: rpc::AddressFamily, + cache_class: CacheClass, + mac_address: MacAddress, + link_address: IpAddr, + circuit_id: &Option, + remote_id: &Option, +) -> String { + format!( + "{:?}_{:?}_{}_{}_{}_{}_", + address_family, + cache_class, mac_address, link_address, match circuit_id { @@ -141,7 +393,6 @@ fn key( Some(rid) => rid.as_str(), None => "", }, - vendor_id, ) } @@ -172,3 +423,121 @@ impl CacheEntryStatus { } } } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use ::rpc::forge as rpc; + + use super::*; + use crate::discovery::Discovery; + + fn test_machine(mac_address: MacAddress, address: Ipv6Addr) -> Machine { + Machine { + inner: rpc::DhcpRecord { + mac_address: mac_address.to_string(), + address: address.to_string(), + ..Default::default() + }, + discovery_info: Discovery { + relay_address: Ipv4Addr::UNSPECIFIED, + mac_address, + _client_system: None, + vendor_class: None, + link_select_address: None, + circuit_id: None, + remote_id: None, + desired_address: None, + }, + vendor_class: None, + } + } + + #[test] + fn invalidated_v6_lease_blocks_stale_positive_cache_reinsert() { + let mac_address = "02:00:00:00:00:aa".parse::().unwrap(); + let address = "2001:db8::aa".parse::().unwrap(); + let link_address = IpAddr::V6("2001:db8::1".parse().unwrap()); + + // Seed and invalidate a cached lease entry, as lease6_expire does. + put_classed( + CacheScope { + address_family: rpc::AddressFamily::V6, + cache_class: CacheClass::Lease, + }, + mac_address, + link_address, + None, + None, + "", + CacheEntryStatus::ValidEntry(Box::new(test_machine(mac_address, address))), + ); + assert_eq!(invalidate_v6_lease(address, mac_address), 1); + + // A concurrent path trying to reinsert the stale API address is ignored + // while the expiry tombstone is still live. + put_classed( + CacheScope { + address_family: rpc::AddressFamily::V6, + cache_class: CacheClass::Lease, + }, + mac_address, + link_address, + None, + None, + "", + CacheEntryStatus::ValidEntry(Box::new(test_machine(mac_address, address))), + ); + assert!( + get_classed( + rpc::AddressFamily::V6, + CacheClass::Lease, + mac_address, + link_address, + &None, + &None, + "", + ) + .is_none() + ); + } + + #[test] + fn cleared_v6_lease_invalidation_allows_positive_cache_reinsert() { + let mac_address = "02:00:00:00:00:ab".parse::().unwrap(); + let address = "2001:db8::ab".parse::().unwrap(); + let link_address = IpAddr::V6("2001:db8::1".parse().unwrap()); + + // A disabled expiry response means the API kept ownership, so the + // temporary tombstone must be removable. + assert_eq!(invalidate_v6_lease(address, mac_address), 0); + assert!(clear_v6_lease_invalidation(address, mac_address)); + + // Once cleared, the same API-owned lease is allowed back into cache. + put_classed( + CacheScope { + address_family: rpc::AddressFamily::V6, + cache_class: CacheClass::Lease, + }, + mac_address, + link_address, + None, + None, + "", + CacheEntryStatus::ValidEntry(Box::new(test_machine(mac_address, address))), + ); + assert!( + get_classed( + rpc::AddressFamily::V6, + CacheClass::Lease, + mac_address, + link_address, + &None, + &None, + "", + ) + .is_some() + ); + } +} diff --git a/crates/dhcp/src/discovery.rs b/crates/dhcp/src/discovery.rs index 0566f3aa93..93177a1c01 100644 --- a/crates/dhcp/src/discovery.rs +++ b/crates/dhcp/src/discovery.rs @@ -19,6 +19,7 @@ use std::net::{IpAddr, Ipv4Addr}; use derive_builder::Builder; use mac_address::MacAddress; +use rpc::forge as rpc; use crate::machine::Machine; use crate::metrics::set_service_healthy; @@ -400,6 +401,7 @@ unsafe fn discovery_fetch_machine_at( let mut cache_entry_status = cache::CacheEntryStatus::DiscoveryFailing(0); if let Some(cache_entry) = cache::get( + rpc::AddressFamily::V4, mac_address, addr_for_dhcp, &circuit_id, @@ -473,6 +475,7 @@ unsafe fn discovery_fetch_machine_at( } cache::put( + rpc::AddressFamily::V4, mac_address, addr_for_dhcp, circuit_id, @@ -488,6 +491,7 @@ unsafe fn discovery_fetch_machine_at( "Error getting info back from the machine discovery: mac={mac_address} addr={addr_for_dhcp} err={e_str} api_url={url}" ); cache::put( + rpc::AddressFamily::V4, mac_address, addr_for_dhcp, circuit_id, diff --git a/crates/dhcp/src/discovery_v6.rs b/crates/dhcp/src/discovery_v6.rs new file mode 100644 index 0000000000..a511b2a763 --- /dev/null +++ b/crates/dhcp/src/discovery_v6.rs @@ -0,0 +1,924 @@ +/* + * 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 decode and identity selection for the Kea hook. +//! +//! This module is intentionally not a general DHCPv6 implementation. Kea still +//! owns the DHCP state machine and `dhcproto` decodes normal client messages; +//! the local raw parsing is limited to the hook boundary where we must recover +//! one-hop relay metadata, enforce relay trust rules, and select the client +//! identity before calling the Carbide API. Kea may provide that relay metadata +//! as a side-channel after unwrapping the packet, or leave it in the raw wire +//! bytes, so this module handles both shapes with the same policy. + +use std::ffi::CString; +use std::net::Ipv6Addr; + +use dhcproto::v6::{DhcpOption, Message, MessageType, OptionCode}; +use dhcproto::{Decodable, Decoder}; +use mac_address::MacAddress; +use rpc::forge as rpc; + +const DHCPV6_RELAY_FORW: u8 = 12; +const DHCPV6_RELAY_REPL: u8 = 13; +const DUID_LLT: u16 = 1; +const DUID_EN: u16 = 2; +const DUID_LL: u16 = 3; +const DUID_UUID: u16 = 4; +const HTYPE_ETHERNET: u16 = 1; +const ETHERNET_MAC_LEN: usize = 6; +const DUID_EN_MIN_LEN: usize = 7; +const DUID_MAX_LEN: usize = 128; +const DUID_UUID_LEN: usize = 18; + +/// Supplemental relay metadata from Kea when it has already unwrapped the relay envelope. +#[derive(Debug, Default, Clone)] +pub struct RelayContext { + pub relay_count: usize, + pub hop_count: u8, + pub link_address: Option, + pub interface_id: Option>, + pub remote_id: Option>, + pub client_link_layer: Option>, +} + +/// DHCPv6 discovery data selected by the hook before calling Carbide. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V6Discovery { + pub selected_mac: MacAddress, + pub duid_mac: Option, + pub duid: Vec, + pub message_type: MessageType, + pub relay_link: Option, + pub vendor_class: Option, + pub interface_id: Option>, + pub remote_id: Option>, + pub desired_addr: Option, + pub ia_addrs: Vec, + pub has_ia_na: bool, + pub client_link_layer: Option, +} + +/// Reasons a DHCPv6 packet cannot be decoded or served by this hook. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V6DecodeError { + MalformedPacket, + NestedRelay, + RelayHopCountExceeded(u8), + MissingDuid, + NoMacNoOption79, + UnsupportedDuid, + UnsupportedMessage(MessageType), +} + +/// Result of parsing a DUID for a link-layer identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DuidMac { + Mac(MacAddress), + NoLinkLayerMac, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DuidError { + Malformed, + UnsupportedType, +} + +#[derive(Debug)] +struct RawOption<'a> { + code: u16, + data: &'a [u8], +} + +/// Decode a DHCPv6 packet without any Kea-provided relay fallback metadata. +#[cfg(test)] +pub fn decode(packet: &[u8]) -> Result { + decode_with_relay_context(packet, &RelayContext::default()) +} + +/// Decode a DHCPv6 packet and select the client identity for the Carbide API call. +pub fn decode_with_relay_context( + packet: &[u8], + relay_context: &RelayContext, +) -> Result { + if relay_context.relay_count > 1 { + return Err(V6DecodeError::NestedRelay); + } + + let decoded = match packet.first().copied() { + Some(DHCPV6_RELAY_FORW) => decode_relay_forward(packet)?, + Some(DHCPV6_RELAY_REPL) => { + return Err(V6DecodeError::UnsupportedMessage(MessageType::RelayRepl)); + } + Some(_) => decode_direct(packet, relay_context)?, + None => return Err(V6DecodeError::MalformedPacket), + }; + + select_identity(decoded) +} + +/// Extract an Ethernet MAC from a DUID-LL or DUID-LLT byte string. +pub fn extract_mac_from_duid(duid: &[u8]) -> Result { + // Kea caps DUIDs at RFC 8415's 128-byte maximum. + if duid.len() < 2 || duid.len() > DUID_MAX_LEN { + return Err(DuidError::Malformed); + } + + let duid_type = u16::from_be_bytes([duid[0], duid[1]]); + match duid_type { + DUID_LLT => parse_link_layer_duid(&duid[2..], 4), + DUID_LL => parse_link_layer_duid(&duid[2..], 0), + DUID_EN if duid.len() >= DUID_EN_MIN_LEN => Ok(DuidMac::NoLinkLayerMac), + DUID_UUID if duid.len() == DUID_UUID_LEN => Ok(DuidMac::NoLinkLayerMac), + DUID_EN | DUID_UUID => Err(DuidError::Malformed), + _ => Err(DuidError::UnsupportedType), + } +} + +/// Parse RFC 6939 option 79 and return an Ethernet MAC when it carries one. +pub fn extract_mac_from_option79(payload: &[u8]) -> Option { + if payload.len() != 2 + ETHERNET_MAC_LEN { + return None; + } + + let htype = u16::from_be_bytes([payload[0], payload[1]]); + (htype == HTYPE_ETHERNET).then(|| { + MacAddress::new([ + payload[2], payload[3], payload[4], payload[5], payload[6], payload[7], + ]) + }) +} + +/// Return a newly allocated MAC string extracted from a DHCPv6 DUID. +/// +/// # Safety +/// `duid_ptr` must be null only when `duid_len` is 0, or point to readable memory of that length. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn carbide_mac_from_duid( + duid_ptr: *const u8, + duid_len: usize, +) -> *mut libc::c_char { + if duid_ptr.is_null() || duid_len == 0 { + return std::ptr::null_mut(); + } + + let duid = unsafe { std::slice::from_raw_parts(duid_ptr, duid_len) }; + match extract_mac_from_duid(duid) { + Ok(DuidMac::Mac(mac)) => CString::new(mac.to_string()) + .map(CString::into_raw) + .unwrap_or_else(|_| std::ptr::null_mut()), + Ok(DuidMac::NoLinkLayerMac) | Err(_) => std::ptr::null_mut(), + } +} + +/// Free a MAC string returned by `carbide_mac_from_duid`. +/// +/// # Safety +/// `mac` must have been returned by this crate and not freed before. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn carbide_free_mac_string(mac: *mut libc::c_char) { + if mac.is_null() { + return; + } + + unsafe { + drop(CString::from_raw(mac)); + } +} + +/// Parse the link-layer payload portion shared by DUID-LL and DUID-LLT. +fn parse_link_layer_duid(bytes: &[u8], payload_offset: usize) -> Result { + if bytes.len() < 2 + payload_offset + ETHERNET_MAC_LEN { + return Err(DuidError::Malformed); + } + + let htype = u16::from_be_bytes([bytes[0], bytes[1]]); + if htype != HTYPE_ETHERNET { + return Err(DuidError::UnsupportedType); + } + + let mac = &bytes[2 + payload_offset..]; + if mac.len() != ETHERNET_MAC_LEN { + return Err(DuidError::Malformed); + } + + Ok(DuidMac::Mac(MacAddress::new([ + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], + ]))) +} + +#[derive(Debug)] +struct DecodedV6 { + message: Message, + relay_link: Option, + interface_id: Option>, + remote_id: Option>, + client_link_layer: Option>, +} + +/// Decode a client packet while preserving Kea-provided relay metadata. +fn decode_direct(packet: &[u8], relay_context: &RelayContext) -> Result { + if relay_context.hop_count > 1 { + return Err(V6DecodeError::RelayHopCountExceeded( + relay_context.hop_count, + )); + } + + // Kea may have stripped the relay envelope already; keep its relay fields + // only as fallback metadata and let the packet body drive message parsing. + let message = + Message::decode(&mut Decoder::new(packet)).map_err(|_| V6DecodeError::MalformedPacket)?; + Ok(DecodedV6 { + message, + relay_link: relay_context.link_address, + interface_id: relay_context.interface_id.clone(), + remote_id: relay_context.remote_id.clone(), + client_link_layer: relay_context.client_link_layer.clone(), + }) +} + +/// Decode one relay-forward envelope and its direct client message. +/// +/// `dhcproto` handles the inner client message, but relay envelopes need a +/// narrow raw-TLV pass here. In `dhcproto 0.15`, option 9 is modeled as another +/// `RelayMessage`; for our authoritative path it normally carries a direct +/// client message, and nested relay is intentionally rejected by policy. +fn decode_relay_forward(packet: &[u8]) -> Result { + if packet.len() < 34 { + return Err(V6DecodeError::MalformedPacket); + } + let hop_count = packet[1]; + if hop_count > 1 { + return Err(V6DecodeError::RelayHopCountExceeded(hop_count)); + } + + let link_address = Ipv6Addr::from( + <[u8; 16]>::try_from(&packet[2..18]).map_err(|_| V6DecodeError::MalformedPacket)?, + ); + let options = parse_raw_options(&packet[34..])?; + let relay_messages = options + .iter() + .filter(|option| option.code == u16::from(OptionCode::RelayMsg)) + .map(|option| option.data) + .collect::>(); + // Kea keeps only one decoded Relay Message for later processing. Reject + // duplicates so identity selection cannot observe a different client. + let relay_message = match relay_messages.as_slice() { + [relay_message] => *relay_message, + _ => return Err(V6DecodeError::MalformedPacket), + }; + + // Nested relay is deliberately unsupported because segment selection would + // otherwise need a clear policy for which relay link-address wins. + if matches!( + relay_message.first().copied(), + Some(DHCPV6_RELAY_FORW) | Some(DHCPV6_RELAY_REPL) + ) { + return Err(V6DecodeError::NestedRelay); + } + + let message = Message::decode(&mut Decoder::new(relay_message)) + .map_err(|_| V6DecodeError::MalformedPacket)?; + Ok(DecodedV6 { + message, + relay_link: Some(link_address), + interface_id: raw_option_bytes(&options, OptionCode::InterfaceId), + remote_id: raw_option_bytes(&options, OptionCode::RemoteId), + client_link_layer: raw_option_bytes(&options, OptionCode::ClientLinklayerAddr), + }) +} + +/// Select the MAC identity and transport fields sent to the Carbide API. +fn select_identity(decoded: DecodedV6) -> Result { + let duid = match decoded.message.opts().get(OptionCode::ClientId) { + Some(DhcpOption::ClientId(duid)) if !duid.is_empty() => duid.clone(), + _ => return Err(V6DecodeError::MissingDuid), + }; + + let duid_mac = match extract_mac_from_duid(&duid) { + Ok(DuidMac::Mac(mac)) => Some(mac), + Ok(DuidMac::NoLinkLayerMac) => None, + Err(_) => return Err(V6DecodeError::UnsupportedDuid), + }; + let client_link_layer = decoded + .client_link_layer + .as_deref() + .and_then(extract_mac_from_option79); + + let selected_mac = match (client_link_layer, duid_mac) { + // RFC 6939 identifies the sending link, so it wins over a DUID MAC. + (Some(client_mac), Some(duid_mac)) => { + if client_mac != duid_mac { + log::warn!( + "DHCPv6 option 79 MAC disagrees with DUID MAC client_mac={client_mac} duid_mac={duid_mac}" + ); + } + client_mac + } + (Some(client_mac), None) => client_mac, + (None, Some(duid_mac)) => duid_mac, + (None, None) => return Err(V6DecodeError::NoMacNoOption79), + }; + + let options = decoded.message.opts(); + let ia_na_count = ia_na_count(options); + let has_ia_na = ia_na_count > 0; + let has_unsupported_ia = + options.get(OptionCode::IATA).is_some() || options.get(OptionCode::IAPD).is_some(); + let ia_addrs = ia_addrs(options); + let desired_addr = ia_addrs.first().copied(); + let vendor_class = vendor_class(options); + let message_type = decoded.message.msg_type(); + let lease_end_message = matches!(message_type, MessageType::Release | MessageType::Decline); + let api_bound_message = message_kind_for(message_type, has_ia_na).is_some(); + + // IA_TA, IA_PD, and multiple IA_NA/address containers cannot be mapped to + // the single-address Carbide API allocation contract. + if api_bound_message && (has_unsupported_ia || ia_na_count > 1 || ia_addrs.len() > 1) { + return Err(V6DecodeError::UnsupportedMessage(message_type)); + } + + // Lease-end and CONFIRM are handled locally; all API-bound request-like + // messages must map to a supported wire contract first. + if !api_bound_message && !matches!(message_type, MessageType::Confirm) && !lease_end_message { + return Err(V6DecodeError::UnsupportedMessage(message_type)); + } + + Ok(V6Discovery { + selected_mac, + duid_mac, + duid, + message_type, + relay_link: decoded.relay_link, + vendor_class, + interface_id: decoded.interface_id, + remote_id: decoded.remote_id, + desired_addr, + ia_addrs, + has_ia_na, + client_link_layer, + }) +} + +/// Map DHCPv6 transport message type to the existing Carbide API message kind. +pub fn message_kind_for(message_type: MessageType, has_ia_na: bool) -> Option { + match message_type { + // Stateless SOLICIT is an information-only observation; stateful + // SOLICIT starts address allocation. + MessageType::Solicit if has_ia_na => Some(rpc::MessageKind::V6Solicit), + MessageType::Solicit => Some(rpc::MessageKind::V6InfoRequest), + MessageType::InformationRequest => Some(rpc::MessageKind::V6InfoRequest), + // The API only needs one request-like value today: REQUEST, RENEW, and + // REBIND with IA_NA all ask Carbide for the same authoritative + // persisted lease, while Kea keeps the DHCP exchange-state differences. + MessageType::Request | MessageType::Renew | MessageType::Rebind if has_ia_na => { + Some(rpc::MessageKind::V6Request) + } + _ => None, + } +} + +/// Count IA_NA containers supplied by the client. +fn ia_na_count(options: &dhcproto::v6::DhcpOptions) -> usize { + options + .get_all(OptionCode::IANA) + .into_iter() + .flatten() + .filter(|option| matches!(option, DhcpOption::IANA(_))) + .count() +} + +/// Return the requested IA_NA addresses supplied by the client. +fn ia_addrs(options: &dhcproto::v6::DhcpOptions) -> Vec { + options + .get_all(OptionCode::IANA) + .into_iter() + .flatten() + .flat_map(|option| match option { + DhcpOption::IANA(ia_na) => ia_na + .opts + .iter() + .filter_map(|option| match option { + DhcpOption::IAAddr(addr) => Some(addr.addr), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }) + .collect() +} + +/// Extract the first vendor-class string that is valid UTF-8. +fn vendor_class(options: &dhcproto::v6::DhcpOptions) -> Option { + match options.get(OptionCode::VendorClass) { + Some(DhcpOption::VendorClass(vendor)) => vendor + .data + .iter() + .find_map(|value| String::from_utf8(value.clone()).ok()), + _ => None, + } +} + +/// Parse raw DHCPv6 option TLVs from a relay envelope. +fn parse_raw_options(mut bytes: &[u8]) -> Result>, V6DecodeError> { + let mut options = Vec::new(); + while !bytes.is_empty() { + if bytes.len() < 4 { + return Err(V6DecodeError::MalformedPacket); + } + + let code = u16::from_be_bytes([bytes[0], bytes[1]]); + let len = u16::from_be_bytes([bytes[2], bytes[3]]) as usize; + bytes = &bytes[4..]; + if bytes.len() < len { + return Err(V6DecodeError::MalformedPacket); + } + + let (data, rest) = bytes.split_at(len); + options.push(RawOption { code, data }); + bytes = rest; + } + + Ok(options) +} + +/// Return an owned copy of one raw relay option payload. +fn raw_option_bytes(options: &[RawOption<'_>], code: OptionCode) -> Option> { + options + .iter() + .find(|option| option.code == u16::from(code)) + .map(|option| option.data.to_vec()) +} + +#[cfg(test)] +mod tests { + use dhcproto::v6::{DhcpOption, IAAddr, IANA, IAPD, IATA, UnknownOption}; + use dhcproto::{Encodable, Encoder}; + + use super::*; + + const DUID_LL: &[u8] = &[0, 3, 0, 1, 2, 0, 0, 0, 0, 1]; + const DUID_LLT: &[u8] = &[0, 1, 0, 1, 1, 2, 3, 4, 2, 0, 0, 0, 0, 1]; + const DUID_UUID: &[u8] = &[0, 4, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + const OPTION79: &[u8] = &[0, 1, 2, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]; + + /// Build a DUID-EN identity with the requested total byte length. + fn duid_en(len: usize) -> Vec { + // Type 2 plus enterprise-number form the minimum DUID-EN prefix. + let mut duid = vec![0, 2, 0, 0, 0, 1]; + duid.resize(len, 0xaa); + duid + } + + fn encode_message(message: Message) -> Vec { + let mut out = Vec::new(); + message.encode(&mut Encoder::new(&mut out)).unwrap(); + out + } + + fn client_message(message_type: MessageType, has_ia_na: bool, duid: &[u8]) -> Message { + let mut message = Message::new_with_id(message_type, [0xaa, 0xbb, 0xcc]); + message + .opts_mut() + .insert(DhcpOption::ClientId(duid.to_vec())); + if has_ia_na { + let mut ia_na = IANA { + id: 1, + t1: 0, + t2: 0, + opts: Default::default(), + }; + ia_na.opts.insert(DhcpOption::IAAddr(IAAddr { + addr: "2001:db8::42".parse().unwrap(), + preferred_life: 300, + valid_life: 600, + opts: Default::default(), + })); + message.opts_mut().insert(DhcpOption::IANA(ia_na)); + } + message + } + + fn option(code: OptionCode, payload: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&u16::from(code).to_be_bytes()); + out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + out.extend_from_slice(payload); + out + } + + fn relay_forward(inner: &[u8], hop_count: u8) -> Vec { + let mut out = vec![12, hop_count]; + out.extend_from_slice(&Ipv6Addr::from(0x20010db8000000000000000000000001u128).octets()); + out.extend_from_slice(&Ipv6Addr::from(0xfe800000000000000000000000000001u128).octets()); + out.extend_from_slice(&option(OptionCode::InterfaceId, b"eth0")); + out.extend_from_slice(&option(OptionCode::RemoteId, b"rack-a")); + out.extend_from_slice(&option(OptionCode::ClientLinklayerAddr, OPTION79)); + out.extend_from_slice(&option(OptionCode::RelayMsg, inner)); + out + } + + /// Build a Relay-Forward carrying two option-9 Relay Message payloads. + fn relay_forward_with_duplicate_relay_message(first: &[u8], second: &[u8]) -> Vec { + let mut out = vec![12, 0]; + out.extend_from_slice(&Ipv6Addr::from(0x20010db8000000000000000000000001u128).octets()); + out.extend_from_slice(&Ipv6Addr::from(0xfe800000000000000000000000000001u128).octets()); + out.extend_from_slice(&option(OptionCode::InterfaceId, b"eth0")); + out.extend_from_slice(&option(OptionCode::RemoteId, b"rack-a")); + out.extend_from_slice(&option(OptionCode::RelayMsg, first)); + out.extend_from_slice(&option(OptionCode::RelayMsg, second)); + out + } + + #[test] + fn extracts_mac_from_supported_duid_types() { + // DUID-LL and DUID-LLT carry the Ethernet MAC in different offsets. + assert_eq!( + extract_mac_from_duid(DUID_LL).unwrap(), + DuidMac::Mac("02:00:00:00:00:01".parse().unwrap()) + ); + assert_eq!( + extract_mac_from_duid(DUID_LLT).unwrap(), + DuidMac::Mac("02:00:00:00:00:01".parse().unwrap()) + ); + } + + #[test] + fn classifies_duids_without_ethernet_mac() { + // Enterprise and UUID DUIDs are valid DHCPv6 identities, but do not + // contain the sending link MAC unless a relay supplies option 79. + assert_eq!( + extract_mac_from_duid(&[0, 2, 0, 0, 0, 1, 0xaa]).unwrap(), + DuidMac::NoLinkLayerMac + ); + assert_eq!( + extract_mac_from_duid(&duid_en(DUID_MAX_LEN)).unwrap(), + DuidMac::NoLinkLayerMac + ); + assert_eq!( + extract_mac_from_duid(DUID_UUID).unwrap(), + DuidMac::NoLinkLayerMac + ); + } + + #[test] + fn rejects_malformed_duids() { + // Truncated and unknown DUID forms cannot safely identify the client. + assert_eq!(extract_mac_from_duid(&[0]), Err(DuidError::Malformed)); + assert_eq!( + extract_mac_from_duid(&[0, 2, 0, 0, 0, 1]), + Err(DuidError::Malformed) + ); + assert_eq!( + extract_mac_from_duid(&[0, 4, 0, 1, 2, 3]), + Err(DuidError::Malformed) + ); + assert_eq!( + extract_mac_from_duid(&duid_en(DUID_MAX_LEN + 1)), + Err(DuidError::Malformed) + ); + assert_eq!( + extract_mac_from_duid(&[0, 99, 0, 1, 2, 3]), + Err(DuidError::UnsupportedType) + ); + assert_eq!( + extract_mac_from_duid(&[0, 3, 0, 32, 2, 0, 0, 0, 0, 1]), + Err(DuidError::UnsupportedType) + ); + } + + #[test] + fn parses_option79_ethernet_mac() { + // Option 79 is decoded as Unknown by dhcproto, so we hand-parse the + // link-layer type and address payload here. + assert_eq!( + extract_mac_from_option79(OPTION79), + Some("02:aa:bb:cc:dd:ee".parse().unwrap()) + ); + assert_eq!(extract_mac_from_option79(&[0, 2, 1, 2, 3, 4, 5, 6]), None); + } + + #[test] + fn decodes_direct_stateful_solicit() { + // Stateful SOLICIT carries IA_NA and maps to the v6 allocation path. + let packet = encode_message(client_message(MessageType::Solicit, true, DUID_LL)); + let decoded = decode(&packet).unwrap(); + + assert_eq!(decoded.selected_mac, "02:00:00:00:00:01".parse().unwrap()); + assert_eq!(decoded.desired_addr, Some("2001:db8::42".parse().unwrap())); + assert_eq!( + message_kind_for(decoded.message_type, decoded.has_ia_na), + Some(rpc::MessageKind::V6Solicit) + ); + } + + #[test] + fn decodes_relay_forward_with_option79_precedence() { + // A one-hop relay supplies segment metadata and option 79; option 79 + // wins over the MAC embedded in DUID-LL/LLT. + let inner = encode_message(client_message(MessageType::Solicit, true, DUID_LL)); + let decoded = decode(&relay_forward(&inner, 1)).unwrap(); + + assert_eq!(decoded.selected_mac, "02:aa:bb:cc:dd:ee".parse().unwrap()); + assert_eq!( + decoded.client_link_layer, + Some("02:aa:bb:cc:dd:ee".parse().unwrap()) + ); + assert_eq!(decoded.relay_link, Some("2001:db8::1".parse().unwrap())); + assert_eq!(decoded.interface_id, Some(b"eth0".to_vec())); + assert_eq!(decoded.remote_id, Some(b"rack-a".to_vec())); + } + + #[test] + fn accepts_unwrapped_packet_with_kea_relay_context() { + // Kea may pass an already-unwrapped client message in data_; in that + // case relay metadata comes from the side-channel context. + let packet = encode_message(client_message( + MessageType::InformationRequest, + false, + DUID_LL, + )); + let decoded = decode_with_relay_context( + &packet, + &RelayContext { + relay_count: 1, + hop_count: 1, + link_address: Some("2001:db8::10".parse().unwrap()), + interface_id: Some(b"swp1".to_vec()), + remote_id: None, + client_link_layer: None, + }, + ) + .unwrap(); + + assert_eq!(decoded.relay_link, Some("2001:db8::10".parse().unwrap())); + assert_eq!(decoded.interface_id, Some(b"swp1".to_vec())); + assert_eq!( + message_kind_for(decoded.message_type, decoded.has_ia_na), + Some(rpc::MessageKind::V6InfoRequest) + ); + } + + #[test] + fn drops_non_mac_duid_without_option79() { + // A DUID-UUID cannot be joined to the v4 MAC row unless option 79 + // supplies the sending link-layer address. + let packet = encode_message(client_message(MessageType::Solicit, true, DUID_UUID)); + + assert_eq!(decode(&packet), Err(V6DecodeError::NoMacNoOption79)); + } + + #[test] + fn ignores_client_supplied_option79_for_non_mac_duid() { + // Option 79 is only trusted when it comes from relay metadata; a + // client-supplied inner option must not choose the Carbide MAC row. + let mut message = client_message(MessageType::Solicit, true, DUID_UUID); + message + .opts_mut() + .insert(DhcpOption::Unknown(UnknownOption::new( + OptionCode::ClientLinklayerAddr, + OPTION79.to_vec(), + ))); + + assert_eq!( + decode(&encode_message(message)), + Err(V6DecodeError::NoMacNoOption79) + ); + } + + #[test] + fn accepts_non_mac_duid_with_relay_option79() { + // Relay-supplied option 79 identifies the sending link for valid + // non-MAC DUIDs. + let inner = encode_message(client_message(MessageType::Solicit, true, DUID_UUID)); + + assert_eq!( + decode(&relay_forward(&inner, 1)).unwrap().selected_mac, + "02:aa:bb:cc:dd:ee".parse().unwrap() + ); + } + + #[test] + fn drops_malformed_duid_even_with_relay_option79() { + // Relay option 79 only helps valid non-MAC DUIDs; malformed or + // unhandled DUID forms are unsupported before MAC selection. + let truncated = encode_message(client_message( + MessageType::Solicit, + true, + &[0, 4, 0, 1, 2, 3], + )); + let non_ethernet = encode_message(client_message( + MessageType::Solicit, + true, + &[0, 3, 0, 32, 2, 0, 0, 0, 0, 1], + )); + + assert_eq!( + decode(&relay_forward(&truncated, 1)), + Err(V6DecodeError::UnsupportedDuid) + ); + assert_eq!( + decode(&relay_forward(&non_ethernet, 1)), + Err(V6DecodeError::UnsupportedDuid) + ); + } + + #[test] + fn rejects_unsupported_ia_shapes_before_api_classification() { + // IA_TA and IA_PD are non-goals and must not be downgraded to + // information-only or stateful API discovery. + let mut solicit_ia_ta = client_message(MessageType::Solicit, false, DUID_LL); + solicit_ia_ta.opts_mut().insert(DhcpOption::IATA(IATA { + id: 1, + opts: Default::default(), + })); + let mut solicit_ia_pd = client_message(MessageType::Solicit, false, DUID_LL); + solicit_ia_pd.opts_mut().insert(DhcpOption::IAPD(IAPD { + id: 1, + t1: 0, + t2: 0, + opts: Default::default(), + })); + + assert_eq!( + decode(&encode_message(solicit_ia_ta)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Solicit)) + ); + assert_eq!( + decode(&encode_message(solicit_ia_pd)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Solicit)) + ); + } + + #[test] + fn rejects_supported_ia_na_when_unsupported_ia_is_also_present() { + // The API can return only one address for one supported IA_NA flow, so + // mixed unsupported IA containers are rejected before lease override. + let mut solicit_ia_ta = client_message(MessageType::Solicit, true, DUID_LL); + solicit_ia_ta.opts_mut().insert(DhcpOption::IATA(IATA { + id: 1, + opts: Default::default(), + })); + let mut request_ia_pd = client_message(MessageType::Request, true, DUID_LL); + request_ia_pd.opts_mut().insert(DhcpOption::IAPD(IAPD { + id: 1, + t1: 0, + t2: 0, + opts: Default::default(), + })); + + assert_eq!( + decode(&encode_message(solicit_ia_ta)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Solicit)) + ); + assert_eq!( + decode(&encode_message(request_ia_pd)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Request)) + ); + } + + #[test] + fn rejects_ambiguous_ia_na_address_selection() { + // The current API contract has one desired address and one returned + // address, so multiple IA_NA containers or IAADDR hints are ambiguous. + let mut second_ia_na = IANA { + id: 2, + t1: 0, + t2: 0, + opts: Default::default(), + }; + second_ia_na.opts.insert(DhcpOption::IAAddr(IAAddr { + addr: "2001:db8::43".parse().unwrap(), + preferred_life: 300, + valid_life: 600, + opts: Default::default(), + })); + let mut multiple_ia_na = client_message(MessageType::Solicit, true, DUID_LL); + multiple_ia_na + .opts_mut() + .insert(DhcpOption::IANA(second_ia_na)); + + let mut multiple_iaaddr = client_message(MessageType::Solicit, true, DUID_LL); + if let Some(DhcpOption::IANA(ia_na)) = multiple_iaaddr.opts_mut().get_mut(OptionCode::IANA) + { + ia_na.opts.insert(DhcpOption::IAAddr(IAAddr { + addr: "2001:db8::43".parse().unwrap(), + preferred_life: 300, + valid_life: 600, + opts: Default::default(), + })); + } + + assert_eq!( + decode(&encode_message(multiple_ia_na)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Solicit)) + ); + assert_eq!( + decode(&encode_message(multiple_iaaddr)), + Err(V6DecodeError::UnsupportedMessage(MessageType::Solicit)) + ); + } + + #[test] + fn accepts_lease_end_messages_with_unsupported_ia_for_kea_handling() { + // RELEASE and DECLINE are Kea protocol paths; Carbide validates + // identity and lets Kea handle them without API discovery. + let mut release = client_message(MessageType::Release, true, DUID_LL); + release.opts_mut().insert(DhcpOption::IATA(IATA { + id: 1, + opts: Default::default(), + })); + let mut decline = client_message(MessageType::Decline, true, DUID_LL); + decline.opts_mut().insert(DhcpOption::IAPD(IAPD { + id: 1, + t1: 0, + t2: 0, + opts: Default::default(), + })); + + assert_eq!( + decode(&encode_message(release)).unwrap().message_type, + MessageType::Release + ); + assert_eq!( + decode(&encode_message(decline)).unwrap().message_type, + MessageType::Decline + ); + } + + #[test] + fn rejects_request_like_messages_without_ia_na() { + // REQUEST, RENEW, and REBIND are stateful paths; without IA_NA there + // is no supported lease request to send to the API. + for message_type in [ + MessageType::Request, + MessageType::Renew, + MessageType::Rebind, + ] { + assert_eq!( + decode(&encode_message(client_message( + message_type, + false, + DUID_LL + ))), + Err(V6DecodeError::UnsupportedMessage(message_type)) + ); + } + } + + #[test] + fn drops_oversized_duid_en_even_with_relay_option79() { + // Relay option 79 may supply the MAC, but the DUID still has to fit + // Kea and RFC 8415 length limits. + let inner = encode_message(client_message( + MessageType::Solicit, + true, + &duid_en(DUID_MAX_LEN + 1), + )); + + assert_eq!( + decode(&relay_forward(&inner, 1)), + Err(V6DecodeError::UnsupportedDuid) + ); + } + + #[test] + fn rejects_nested_or_multi_hop_relay() { + // Multi-hop relay handling needs an explicit segment precedence rule, + // so this milestone rejects it instead of serving silently. + let inner = encode_message(client_message(MessageType::Solicit, true, DUID_LL)); + let nested = relay_forward(&relay_forward(&inner, 1), 1); + + assert_eq!( + decode(&relay_forward(&inner, 2)), + Err(V6DecodeError::RelayHopCountExceeded(2)) + ); + assert_eq!(decode(&nested), Err(V6DecodeError::NestedRelay)); + } + + #[test] + fn rejects_duplicate_relay_message_options() { + // Multiple option-9 payloads can make Rust and Kea inspect different + // inner clients, so reject before choosing an identity. + let first = encode_message(client_message(MessageType::Solicit, true, DUID_LL)); + let second = encode_message(client_message(MessageType::Solicit, true, DUID_LLT)); + + assert_eq!( + decode(&relay_forward_with_duplicate_relay_message(&first, &second)), + Err(V6DecodeError::MalformedPacket) + ); + } +} diff --git a/crates/dhcp/src/kea/callouts.cc b/crates/dhcp/src/kea/callouts.cc index 5760033b71..fad49db7ed 100644 --- a/crates/dhcp/src/kea/callouts.cc +++ b/crates/dhcp/src/kea/callouts.cc @@ -17,10 +17,135 @@ #include "callouts.h" #include "carbide_rust.h" +#include +#include +#include isc::log::Logger logger("carbide-callouts"); const int IPV4_ADDR_SIZEB = 4; +const int IPV6_ADDR_SIZEB = 16; + +const uint8_t *nullable_data(const OptionBuffer &buffer) { + return buffer.empty() ? nullptr : buffer.data(); +} + +// Own a Rust DHCP byte buffer for the duration of C++ option construction. +class DhcpByteBufferGuard { +public: + explicit DhcpByteBufferGuard(DhcpByteBuffer buffer) : buffer_(buffer) {} + ~DhcpByteBufferGuard() { machine_free_dhcp_byte_buffer(buffer_); } + DhcpByteBufferGuard(const DhcpByteBufferGuard &) = delete; + DhcpByteBufferGuard &operator=(const DhcpByteBufferGuard &) = delete; + + bool empty() const { return buffer_.len == 0 || buffer_.ptr == nullptr; } + + OptionBuffer optionBuffer() const { + return OptionBuffer(buffer_.ptr, buffer_.ptr + buffer_.len); + } + +private: + DhcpByteBuffer buffer_; +}; + +// Extract a DHCPv6 relay-option payload for the Rust decoder, returning an +// empty buffer when Kea did not retain that relay metadata. +OptionBuffer option_data(const Pkt6::RelayInfo &relay, uint16_t code) { + auto option = relay.options_.find(code); + if (option == relay.options_.end() || !option->second) { + return OptionBuffer(); + } + return option->second->getData(); +} + +void add_or_replace_option6(Pkt6Ptr response6_ptr, uint16_t code, + DhcpByteBuffer buffer) { + DhcpByteBufferGuard guard(buffer); + + // Delete first so an intentionally empty trusted value removes any + // client-derived option already present on Kea's response. + response6_ptr->delOption(code); + if (guard.empty()) { + return; + } + + OptionBuffer payload = guard.optionBuffer(); + response6_ptr->addOption(OptionPtr(new Option(Option::V6, code, payload))); +} + +void add_or_replace_option6(Pkt6Ptr response6_ptr, uint16_t code, + const OptionBuffer &buffer) { + if (buffer.empty()) { + return; + } + + response6_ptr->delOption(code); + response6_ptr->addOption(OptionPtr(new Option(Option::V6, code, buffer))); +} + +void add_client_fqdn_option6(Pkt6Ptr query6_ptr, Pkt6Ptr response6_ptr, + Machine *machine) { + response6_ptr->delOption(D6O_CLIENT_FQDN); + + OptionPtr requested = + query6_ptr ? query6_ptr->getOption(D6O_CLIENT_FQDN) : OptionPtr(); + if (!requested || requested->getData().empty()) { + return; + } + + DhcpByteBuffer buffer = machine_get_client_fqdn_ipv6(machine); + DhcpByteBufferGuard guard(buffer); + if (guard.empty()) { + return; + } + + OptionBuffer payload = guard.optionBuffer(); + // Preserve client negotiation flags, but keep the API-owned hostname. + payload[0] = requested->getData()[0]; + response6_ptr->addOption( + OptionPtr(new Option(Option::V6, D6O_CLIENT_FQDN, payload))); +} + +void add_status6(Pkt6Ptr response6_ptr, uint16_t status, + const std::string &message) { + response6_ptr->delOption(D6O_STATUS_CODE); + response6_ptr->addOption( + OptionPtr(new Option6StatusCode(status, message))); +} + +void add_ia_na_status6(Pkt6Ptr query6_ptr, Pkt6Ptr response6_ptr, + uint16_t status, const std::string &message) { + uint32_t iaid = 0; + if (query6_ptr) { + OptionPtr option = query6_ptr->getOption(D6O_IA_NA); + Option6IAPtr query_ia_na = + boost::dynamic_pointer_cast(option); + if (query_ia_na) { + iaid = query_ia_na->getIAID(); + } + } + + // Replace Kea's old-address IA_NA success with an IA-scoped failure. + response6_ptr->delOption(D6O_STATUS_CODE); + response6_ptr->delOption(D6O_IA_NA); + Option6IAPtr ia_na(new Option6IA(D6O_IA_NA, iaid)); + ia_na->addOption(OptionPtr(new Option6StatusCode(status, message))); + response6_ptr->addOption(ia_na); +} + +void record_dropped_v6_request(const char *reason) { + // Preserve the shared v4/v6 counter while emitting the required v6 series. + carbide_increment_dropped_requests(reason); + carbide_increment_dropped_v6_requests(reason); +} + +/// Records a DHCPv6 response that Kea is still allowed to send. +void record_v6_reply_sent(CalloutHandle &handle, Pkt6Ptr response6_ptr) { + // Count only responses Kea will be allowed to put on the wire. + if (response6_ptr && handle.getStatus() != CalloutHandle::NEXT_STEP_DROP) { + carbide_increment_v6_reply_sent(response6_ptr->getType()); + } +} void CDHCPOptionsHandler