Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions crates/agent/src/dhcp_server_grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ impl From<ModelDhcpConfig> 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(),
}
}
}
Expand Down Expand Up @@ -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()],
);
}
}
6 changes: 6 additions & 0 deletions crates/agent/src/ethernet_virtualization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions crates/dhcp-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ chrono = { workspace = true }
futures = { workspace = true }

[dev-dependencies]
carbide-test-support = { path = "../test-support" }
tempfile = { workspace = true }

[build-dependencies]
Expand Down
4 changes: 4 additions & 0 deletions crates/dhcp-server/proto/dhcp_server_control.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions crates/dhcp-server/src/grpc_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ impl TryFrom<proto::DhcpConfig> for ModelDhcpConfig {
.collect::<Result<Vec<_>, _>>()?,
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::<Result<Vec<_>, _>>()?,
})
}
}
Expand Down Expand Up @@ -217,3 +222,57 @@ pub async fn run_grpc_server(addr: SocketAddr, ctrl_tx: mpsc::Sender<ControlRequ
tracing::error!("gRPC server exited with error: {}", e);
}
}

#[cfg(test)]
mod tests {
use std::net::Ipv6Addr;

use carbide_test_support::Outcome::*;
use carbide_test_support::scenarios;

use super::*;

fn base_proto_config() -> 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<String>) -> Result<Vec<Ipv6Addr>, &'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::<Ipv6Addr>().unwrap(),
"2001:db8::2".parse::<Ipv6Addr>().unwrap(),
]),
}

"malformed ipv6 nameserver" {
vec!["not-an-ip".to_string()] => Fails,
}
);
}
}
34 changes: 33 additions & 1 deletion crates/rpc-utils/src/dhcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +40,12 @@ pub struct DhcpConfig {
pub carbide_ntpservers: Vec<Ipv4Addr>,
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<Ipv6Addr>,
}

#[derive(thiserror::Error, Debug)]
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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:
Expand Down
Loading