From 7e9c6c9de0685ae913b3fb4fc0ce34325903bf10 Mon Sep 17 00:00:00 2001 From: Chet Nichols III Date: Tue, 16 Jun 2026 15:17:18 -0700 Subject: [PATCH] feat(dhcp): hold IPv6 DNS resolvers in DhcpConfig alongside IPv4 The DPU agent already learns both IPv4 and IPv6 DNS resolvers for a host (the dual-stack `ServiceAddresses.nameservers`), but the `DhcpConfig` it hands to `forge-dhcp-server` only holds the IPv4 ones. This gives the config a parallel `carbide_nameservers_v6`, in both the `dhcp_server_control` proto and the `DhcpConfig` model, so a host's IPv6 resolvers have somewhere to live across the agent -> dhcp-server channel. This is the data-model foundation for advertising dual-stack DNS resolvers to IPv6 instances. The pieces that fill it in -- populating the field from the agent, and choosing how the address reaches a host (RA RDNSS or DHCPv6) -- are follow-on work. - The IPv4 `carbide_nameservers` field is unchanged, so the DHCPv4 option-6 path behaves exactly as before. - `carbide_nameservers_v6` is omitted from the serialized config when empty, so the on-disk format is identical for IPv4-only deployments and configs written by older agents still deserialize. Tests cover the new field round-tripping through serde and through both proto conversions, including a malformed-address rejection. This supports https://github.com/NVIDIA/infra-controller/issues/2638. Signed-off-by: Chet Nichols III --- Cargo.lock | 1 + crates/agent/src/dhcp_server_grpc_client.rs | 26 ++++++++ crates/agent/src/ethernet_virtualization.rs | 6 ++ crates/dhcp-server/Cargo.toml | 1 + .../proto/dhcp_server_control.proto | 4 ++ crates/dhcp-server/src/grpc_server.rs | 59 +++++++++++++++++++ crates/rpc-utils/src/dhcp.rs | 34 ++++++++++- 7 files changed, 130 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0a311b13a9..42cdab458c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,6 +1619,7 @@ version = "0.1.0" dependencies = [ "carbide-rpc", "carbide-rpc-utils", + "carbide-test-support", "carbide-tls", "carbide-uuid", "chrono", diff --git a/crates/agent/src/dhcp_server_grpc_client.rs b/crates/agent/src/dhcp_server_grpc_client.rs index dc0c4196a0..00d423bb28 100644 --- a/crates/agent/src/dhcp_server_grpc_client.rs +++ b/crates/agent/src/dhcp_server_grpc_client.rs @@ -47,6 +47,11 @@ impl From for proto::DhcpConfig { .collect(), carbide_provisioning_server_ipv4: c.carbide_provisioning_server_ipv4.to_string(), carbide_dhcp_server: c.carbide_dhcp_server.to_string(), + carbide_nameservers_v6: c + .carbide_nameservers_v6 + .iter() + .map(|ip| ip.to_string()) + .collect(), } } } @@ -171,3 +176,24 @@ pub async fn update_and_reload( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_ipv6_nameservers_to_proto() { + let model = ModelDhcpConfig { + carbide_nameservers_v6: vec![ + "2001:db8::1".parse().unwrap(), + "2001:db8::2".parse().unwrap(), + ], + ..Default::default() + }; + let proto = proto::DhcpConfig::from(model); + assert_eq!( + proto.carbide_nameservers_v6, + vec!["2001:db8::1".to_string(), "2001:db8::2".to_string()], + ); + } +} diff --git a/crates/agent/src/ethernet_virtualization.rs b/crates/agent/src/ethernet_virtualization.rs index 4aed1f323b..c7785dbe73 100644 --- a/crates/agent/src/ethernet_virtualization.rs +++ b/crates/agent/src/ethernet_virtualization.rs @@ -3317,6 +3317,10 @@ mod tests { expected.carbide_provisioning_server_ipv4 ); assert_eq!(received.carbide_dhcp_server, expected.carbide_dhcp_server); + assert_eq!( + received.carbide_nameservers_v6, + expected.carbide_nameservers_v6 + ); } fn validate_host_config(received: HostConfig, expected: HostConfig) { @@ -3463,6 +3467,7 @@ mod tests { rebinding_time_secs: 432000, carbide_api_url: None, carbide_dhcp_server: Ipv4Addr::from([10, 217, 5, 39]), + carbide_nameservers_v6: vec![], }; let mut network_config = rpc::ManagedHostNetworkConfigResponse { @@ -3656,6 +3661,7 @@ mod tests { rebinding_time_secs: 432000, carbide_api_url: None, carbide_dhcp_server: Ipv4Addr::from([10, 217, 5, 39]), + carbide_nameservers_v6: vec![], }; let dhcp_contents = super::read_limited(g.path())?; assert!(dhcp_contents.contains("vlan196")); diff --git a/crates/dhcp-server/Cargo.toml b/crates/dhcp-server/Cargo.toml index b566dfea31..8edd0ed5b6 100644 --- a/crates/dhcp-server/Cargo.toml +++ b/crates/dhcp-server/Cargo.toml @@ -59,6 +59,7 @@ chrono = { workspace = true } futures = { workspace = true } [dev-dependencies] +carbide-test-support = { path = "../test-support" } tempfile = { workspace = true } [build-dependencies] diff --git a/crates/dhcp-server/proto/dhcp_server_control.proto b/crates/dhcp-server/proto/dhcp_server_control.proto index 09d8a28161..90b5b7dc18 100644 --- a/crates/dhcp-server/proto/dhcp_server_control.proto +++ b/crates/dhcp-server/proto/dhcp_server_control.proto @@ -39,6 +39,7 @@ service DhcpServerControl { // Mirrors utils::models::dhcp::DhcpConfig. // IPv4 addresses are encoded as dotted-decimal strings (e.g. "192.168.1.1"). +// IPv6 addresses are encoded as colon-notation strings (e.g. "2001:db8::1"). message DhcpConfig { uint32 lease_time_secs = 1; uint32 renewal_time_secs = 2; @@ -48,6 +49,9 @@ message DhcpConfig { repeated string carbide_ntpservers = 6; string carbide_provisioning_server_ipv4 = 7; string carbide_dhcp_server = 8; + // IPv6 DNS server addresses for DHCPv6 option 23 (DNS Recursive Name Server). + // Empty when no IPv6 nameservers are configured. + repeated string carbide_nameservers_v6 = 9; } // Mirrors utils::models::dhcp::InterfaceInfo. diff --git a/crates/dhcp-server/src/grpc_server.rs b/crates/dhcp-server/src/grpc_server.rs index f7ab8e3b97..4f197a581f 100644 --- a/crates/dhcp-server/src/grpc_server.rs +++ b/crates/dhcp-server/src/grpc_server.rs @@ -76,6 +76,11 @@ impl TryFrom for ModelDhcpConfig { .collect::, _>>()?, carbide_provisioning_server_ipv4: c.carbide_provisioning_server_ipv4.parse()?, carbide_dhcp_server: c.carbide_dhcp_server.parse()?, + carbide_nameservers_v6: c + .carbide_nameservers_v6 + .iter() + .map(|s| s.parse()) + .collect::, _>>()?, }) } } @@ -217,3 +222,57 @@ pub async fn run_grpc_server(addr: SocketAddr, ctrl_tx: mpsc::Sender proto::DhcpConfig { + proto::DhcpConfig { + lease_time_secs: 1, + renewal_time_secs: 2, + rebinding_time_secs: 3, + carbide_nameservers: vec!["10.0.0.1".to_string()], + carbide_api_url: None, + carbide_ntpservers: vec![], + carbide_provisioning_server_ipv4: "10.0.0.2".to_string(), + carbide_dhcp_server: "10.0.0.3".to_string(), + carbide_nameservers_v6: vec![], + } + } + + /// Convert a proto `DhcpConfig` carrying `v6` nameservers (atop a valid IPv4 + /// base) and surface the parsed IPv6 set, mapping a parse failure to a static + /// error kind so success and failure cases share one table. + fn parse_proto_v6_nameservers(v6: Vec) -> Result, &'static str> { + let proto = proto::DhcpConfig { + carbide_nameservers_v6: v6, + ..base_proto_config() + }; + ModelDhcpConfig::try_from(proto) + .map(|model| model.carbide_nameservers_v6) + .map_err(|_| "invalid-ipv6") + } + + #[test] + fn parses_ipv6_nameservers_from_proto() { + scenarios!(parse_proto_v6_nameservers: + "valid ipv6 nameservers" { + vec!["2001:db8::1".to_string(), "2001:db8::2".to_string()] + => Yields(vec![ + "2001:db8::1".parse::().unwrap(), + "2001:db8::2".parse::().unwrap(), + ]), + } + + "malformed ipv6 nameserver" { + vec!["not-an-ip".to_string()] => Fails, + } + ); + } +} diff --git a/crates/rpc-utils/src/dhcp.rs b/crates/rpc-utils/src/dhcp.rs index 212748b6b1..ed97bb5030 100644 --- a/crates/rpc-utils/src/dhcp.rs +++ b/crates/rpc-utils/src/dhcp.rs @@ -16,7 +16,7 @@ */ use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use carbide_uuid::UuidConversionError; @@ -40,6 +40,12 @@ pub struct DhcpConfig { pub carbide_ntpservers: Vec, pub carbide_provisioning_server_ipv4: Ipv4Addr, pub carbide_dhcp_server: Ipv4Addr, + /// IPv6 DNS server addresses advertised via DHCPv6 option 23 (DNS Recursive Name Server). + /// Empty when no IPv6 nameservers are configured. Omitted from the serialized config when + /// empty, so the on-disk format is unchanged for IPv4-only deployments and configs written + /// by older agents (which lack the field) still deserialize. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub carbide_nameservers_v6: Vec, } #[derive(thiserror::Error, Debug)] @@ -68,6 +74,7 @@ impl Default for DhcpConfig { carbide_nameservers: vec![], carbide_api_url: None, carbide_ntpservers: vec![], + carbide_nameservers_v6: vec![], // These two must be updated with valid values. carbide_provisioning_server_ipv4: Ipv4Addr::from([127, 0, 0, 1]), @@ -468,6 +475,31 @@ mod tests { ); } + #[test] + fn carries_ipv6_nameservers_through_serde() { + let config = DhcpConfig { + carbide_nameservers_v6: vec![ + "2001:db8::1".parse().unwrap(), + "2001:db8::2".parse().unwrap(), + ], + ..Default::default() + }; + + let serialized = serde_json::to_string(&config).unwrap(); + let restored: DhcpConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + restored.carbide_nameservers_v6, + config.carbide_nameservers_v6 + ); + + // An empty v6 list is omitted, so IPv4-only configs serialize unchanged and + // a config without the field still deserializes (the field defaults to empty). + let v4_only = serde_json::to_string(&DhcpConfig::default()).unwrap(); + assert!(!v4_only.contains("carbide_nameservers_v6")); + let restored_v4: DhcpConfig = serde_json::from_str(&v4_only).unwrap(); + assert!(restored_v4.carbide_nameservers_v6.is_empty()); + } + #[test] fn converts_flat_interface_config() { scenarios!(summarize_flat_interface: