From 46c19819c72b30921e4ae2517fd68d68cd9a13f9 Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Thu, 30 Apr 2026 20:58:04 +0000 Subject: [PATCH 1/5] Ensure we use a stable DUID for DHCPv6 exchanges - Package `ndpd.conf` in the switch zone with defaults that preclude DHCPv6 on any interface. - After fetching the correct, stable MAC addresses from the switch SP, `dpd` now uses the base MAC to write out a DUID to a file where illumos's `dhpcagent` can pick it up and use it later in exchanges. It then starts DHCP on that interface, after ensuring a link-local IPv6 address derived from that MAC address exists as well. - Some misc cleanup, logging improvements, `IdOrdMap` over `BTreeMap` --- Cargo.lock | 44 ++-- Cargo.toml | 1 + common/src/illumos.rs | 12 +- common/src/network.rs | 5 + dpd/Cargo.toml | 1 + dpd/misc/ndpd.conf | 8 + dpd/src/api_server.rs | 2 +- dpd/src/dhcpv6.rs | 102 +++++++++ dpd/src/dhcpv6/dummy.rs | 16 ++ dpd/src/dhcpv6/illumos.rs | 341 +++++++++++++++++++++++++++++++ dpd/src/macaddrs.rs | 51 +++-- dpd/src/main.rs | 44 ++-- tfportd/Cargo.toml | 1 + tfportd/src/linklocal.rs | 12 +- tfportd/src/ports.rs | 61 +++--- tfportd/src/vlans.rs | 103 +++++++--- tools/omicron-asic-manifest.toml | 3 +- uplinkd/src/main.rs | 7 +- xtask/src/codegen.rs | 5 +- 19 files changed, 689 insertions(+), 130 deletions(-) create mode 100644 dpd/misc/ndpd.conf create mode 100644 dpd/src/dhcpv6.rs create mode 100644 dpd/src/dhcpv6/dummy.rs create mode 100644 dpd/src/dhcpv6/illumos.rs diff --git a/Cargo.lock b/Cargo.lock index 75952de7..495f734b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -927,7 +927,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1308,9 +1308,9 @@ dependencies = [ [[package]] name = "daft" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce0bf145758082da552af574185d4726000cd4b6e017d7966c99e7b2a5a086ce" +checksum = "b6a26f1f0a7934549bf8d8448d9da072c31f14e1e407b6cbacfdc07b3777988e" dependencies = [ "daft-derive", "newtype-uuid", @@ -1321,9 +1321,9 @@ dependencies = [ [[package]] name = "daft-derive" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ad40aef90652e771af668d28abcc3ef35fd0d39438706a76a61588cf8e8e84a" +checksum = "27c6a4a4003df965e441d13b2a7044efa44334b567c984701f8a2773f815c5e2" dependencies = [ "proc-macro2", "quote", @@ -1587,6 +1587,7 @@ dependencies = [ "aal_macros", "anyhow", "asic", + "bytes", "cfg-if", "chrono", "clap", @@ -2912,7 +2913,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration", "tokio", "tower-layer", @@ -3033,9 +3034,9 @@ dependencies = [ [[package]] name = "iddqd" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b215e67ed1d1a4b1702acd787c487d16e4c977c5dcbcc4587bdb5ea26b6ce06" +checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca" dependencies = [ "allocator-api2", "daft", @@ -3637,9 +3638,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -5601,7 +5602,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.18", "tokio", "tracing", @@ -5638,7 +5639,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", "windows-sys 0.60.2", ] @@ -5995,14 +5996,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.0", ] @@ -7192,14 +7193,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.0", ] @@ -7218,7 +7219,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -7275,6 +7276,7 @@ dependencies = [ "dpd-client 0.1.0", "futures", "http", + "iddqd", "internet-checksum", "ispf", "kstat-rs", @@ -8686,7 +8688,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.0", ] [[package]] @@ -9076,7 +9078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 51a04a23..b5d9b79e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ expectorate = "1" futures = "0.3" http = "1.4.0" humantime = "2.3" +iddqd = "0.3.18" kstat-rs = "0.2.4" lazy_static = "1.5" libc = "0.2" diff --git a/common/src/illumos.rs b/common/src/illumos.rs index 5eca9c2b..d3277981 100644 --- a/common/src/illumos.rs +++ b/common/src/illumos.rs @@ -14,6 +14,11 @@ pub mod smf; type Result = std::result::Result; +/// The suffix for the addrobj name for IPv6 link-local addresses on each tfport. +/// +/// E.g., all IPv6 addresses are named like `tfportrear0_0/ll`. +pub const IPV6_LINK_LOCAL_NAME: &str = "ll"; + #[derive(Debug, PartialEq, thiserror::Error)] pub enum IllumosError { /// This error indicates that the requested command wasn't able to run @@ -147,11 +152,8 @@ pub async fn address_add( let addr_obj = format!("{iface}/{tag}"); let addr: oxnet::IpNet = addr.into(); - if addr.is_ipv6() { - let tag = "ll"; - if !address_exists(iface, tag).await? { - linklocal_add(iface, tag).await?; - } + if addr.is_ipv6() && !address_exists(iface, IPV6_LINK_LOCAL_NAME).await? { + linklocal_add(iface, IPV6_LINK_LOCAL_NAME).await?; } let addr = addr.to_string(); diff --git a/common/src/network.rs b/common/src/network.rs index 6727804b..1a448820 100644 --- a/common/src/network.rs +++ b/common/src/network.rs @@ -129,6 +129,11 @@ impl MacAddr { self.a[5], ] } + + /// Return the bytes of the MAC as a slice. + pub const fn as_slice(&self) -> &[u8] { + self.a.as_slice() + } } #[derive(Error, Debug, Clone)] diff --git a/dpd/Cargo.toml b/dpd/Cargo.toml index 7a15905f..400b9172 100644 --- a/dpd/Cargo.toml +++ b/dpd/Cargo.toml @@ -31,6 +31,7 @@ dpd-types.workspace = true dpd-types-versions.workspace = true anyhow.workspace = true +bytes.workspace = true cfg-if.workspace = true chrono.workspace = true clap.workspace = true diff --git a/dpd/misc/ndpd.conf b/dpd/misc/ndpd.conf new file mode 100644 index 00000000..f56e124e --- /dev/null +++ b/dpd/misc/ndpd.conf @@ -0,0 +1,8 @@ +# Do not run DHCPv6 on any interfaces by default. +# +# DHCPv6 servers rely on unique identifiers, DUIDs, to identify clients. +# Those are usually based on the link-layer address. The Omicron switch zone +# starts with a random locally-administered MAC, which means we _don't_ have a +# stable ID. Dendrite software manually creates the DUID once we've collected +# stable MAC addresses from the Sidecar SP. +ifdefault StatefulAddrConf false diff --git a/dpd/src/api_server.rs b/dpd/src/api_server.rs index 3025d3cc..acc21638 100644 --- a/dpd/src/api_server.rs +++ b/dpd/src/api_server.rs @@ -2933,7 +2933,7 @@ fn path_to_qsfp(path: Path) -> Result { } } -fn build_info() -> BuildInfo { +pub(crate) fn build_info() -> BuildInfo { BuildInfo { version: env!("CARGO_PKG_VERSION").to_string(), git_sha: env!("VERGEN_GIT_SHA").to_string(), diff --git a/dpd/src/dhcpv6.rs b/dpd/src/dhcpv6.rs new file mode 100644 index 00000000..11d607bc --- /dev/null +++ b/dpd/src/dhcpv6.rs @@ -0,0 +1,102 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +//! Code for managing DHCPv6 addresses on the technician ports. +//! +//! Some customers expect to lease IPv6 addresses to the technician ports using +//! DHCPv6. That protocol requires a stable client identifier, which is commonly +//! based on the MAC address of an interface on the host. However, we don't have +//! a stable MAC address on startup. The switch zone is started with a random +//! locally-administered MAC address for its bootstrap VNIC, which isn't an +//! acceptable basis for the client ID. We _do_ have a stable, unique MAC +//! address once we've fetched them from the switch VPD during bootstrapping. +//! But that bootstrapping requires a temporary, random MAC address, over which +//! we fetch the real MAC address. +//! +//! This presents a bit of a problem. `tfportd` is normally responsible for +//! creating and assigning IP addresses to the technician ports (along with all +//! the other interfaces). But it can't reliably be responsible for initiating +//! the DHCPv6 negotiation. `tfportd` does not and cannot know when we've +//! actually collected the stable MAC address from the switch VPD. It sees the +//! initial random MAC address, and creates the VLANs for Dendrite to fetch the +//! switch VPD. It then sees the new, real MAC address, and recreates all those +//! VLANs based on that. So `tfportd` doesn't really know the difference between +//! the initial random and real MAC addresses. +//! +//! Instead, we're intentionally violating the separation between `dpd` and +//! `tfportd` in this module. Dendrite knows when it's gotten the real MAC +//! addresses, and so it knows when DHCPv6 can proceed using a stable client ID. +//! Here we write that stable ID once we have it, wait for `tfportd` to create +//! the corresponding link-local IPv6 address on the technician ports, and then +//! start the DHCP agent running on those interfaces too. DHCP is not run on any +//! other interfaces at all. + +#[cfg_attr(target_os = "illumos", path = "dhcpv6/illumos.rs")] +#[cfg_attr(not(target_os = "illumos"), path = "dhcpv6/dummy.rs")] +mod dhcpv6_impl; + +use common::network::MacAddr; +use slog::Logger; + +/// Ensure that DHCPv6 is running on the technician ports. +/// +/// This is a small reconciler that continually ensures: +/// +/// - The client-identifier is written to disk +/// - The DHCP agent is running on the technician ports. +pub(crate) async fn ensure_dhcpv6_agent(log: Logger, base_mac: MacAddr) { + dhcpv6_impl::ensure_dhcpv6_agent(log, base_mac).await +} + +#[cfg(any(target_os = "illumos", test))] +pub fn create_duid_bytes(base_mac: &MacAddr) -> Vec { + use bytes::BufMut as _; + + // To ensure we have a _stable_ DUID, which doesn't change during zone + // reboots, we use only the link-layer address. + // + // See https://www.rfc-editor.org/rfc/rfc8415#section-11.4 for details. + const DUID_TYPE: u16 = 0x03; + + // We're running on Ethernet links. + // + // See + // https://www.iana.org/assignments/arp-parameters/arp-parameters.xhtml#arp-parameters-2. + const HARDWARE_TYPE: u16 = 0x01; + + // illumos creates its DUIDs using the `make_stable_duid()` function + // defined here: + // https://github.com/oxidecomputer/illumos-gate/blob/71b1f26fe641fba9ad5b9bca63cb9d00024578e5/usr/src/lib/libdhcpagent/common/dhcp_stable.c#L130. + // + // Importantly, it does no interpretation of the contents of the file + // when _using_ the DUID in a DHCPv6 exchange, so we're writing out the + // literal contents of the DUID-LL object, defined here: + // https://www.rfc-editor.org/rfc/rfc8415#section-11.4. + let sl = base_mac.as_slice(); + let mut bytes = Vec::with_capacity( + std::mem::size_of::() + std::mem::size_of::() + sl.len(), + ); + bytes.put_u16(DUID_TYPE); + bytes.put_u16(HARDWARE_TYPE); + bytes.put(sl); + + bytes +} + +#[cfg(test)] +mod tests { + use super::create_duid_bytes; + + #[tokio::test] + async fn test_create_duid_bytes() { + let mac = [0xa8, 0x40, 0x25, 0xfe, 0xfe, 0xfe]; + let bytes = create_duid_bytes(&mac.into()); + assert_eq!(bytes.len(), 2 + 2 + 6); + assert_eq!(u16::from_be_bytes(bytes[..2].try_into().unwrap()), 3); + assert_eq!(u16::from_be_bytes(bytes[2..4].try_into().unwrap()), 1); + assert_eq!(&bytes[4..], &mac); + } +} diff --git a/dpd/src/dhcpv6/dummy.rs b/dpd/src/dhcpv6/dummy.rs new file mode 100644 index 00000000..f8d63410 --- /dev/null +++ b/dpd/src/dhcpv6/dummy.rs @@ -0,0 +1,16 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +use common::network::MacAddr; +use slog::Logger; + +pub async fn ensure_dhcpv6_agent(log: Logger, _base_mac: MacAddr) { + slog::debug!( + log, + "Not running DHCPv6 agent. This software is not built for \ + both illumos and the Tofino ASIC feature"; + ); +} diff --git a/dpd/src/dhcpv6/illumos.rs b/dpd/src/dhcpv6/illumos.rs new file mode 100644 index 00000000..3d715380 --- /dev/null +++ b/dpd/src/dhcpv6/illumos.rs @@ -0,0 +1,341 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/ +// +// Copyright 2026 Oxide Computer Company + +use common::illumos::IPV6_LINK_LOCAL_NAME; +use common::network::MacAddr; +use common::network::generate_ipv6_link_local; +use slog::Logger; +use slog::debug; +use slog::error; +use slog::info; +use slog::warn; +use std::net::Ipv6Addr; +use std::process::Output; +use std::time::Duration; + +const IPADM: &str = "/usr/sbin/ipadm"; +const IFCONFIG: &str = "/usr/sbin/ifconfig"; +const DUID_PATH: &str = "/etc/dhcp/duid"; +const TEMP_DUID_PATH: &str = "/etc/dhcp/duid.temp"; +const TECHPORTS: [&str; 2] = ["techport0", "techport1"]; + +// From illumos source: `usr/include/dhcpagent_ipc.h:62` +const DHCP_EXIT_FAILURE: i32 = 2; + +// From illumos source: `usr/include/dhcpagent_ipc.h:649` +const DHCP_IS_ALREADY_RUNNING_MSG: &[u8] = b"DHCP is already running"; + +// From illumos source: `usr/include/dhcpagent_ipc.h:606` +const DHCP_HAS_PENDING_COMMAND: &[u8] = + b"interface curently has a pending command (try later)"; + +/// Ensure that the DHCP agent is running on the techports, checking that they +/// have the provided base MAC address. +pub async fn ensure_dhcpv6_agent(log: Logger, base_mac: MacAddr) { + const INTERVAL: Duration = Duration::from_secs(10); + loop { + info!(log, "starting DHCPv6 agent loop"; "base_mac" => %base_mac); + if let Err(e) = ensure_duid_file_exists(&log, &base_mac).await { + error!( + log, + "failed to ensure DUID file"; + "error" => %e, + ); + continue; + }; + start_dhcpv6_agent(&log, &base_mac).await; + tokio::time::sleep(INTERVAL).await; + } +} + +/// Start the agent, if the techports have the expected IPv6 link-local address. +async fn start_dhcpv6_agent(log: &Logger, base_mac: &MacAddr) { + for techport in TECHPORTS { + if !has_correct_ipv6_link_local(log, techport, base_mac).await { + warn!( + log, + "techport does not yet have correct IPv6 link-local"; + "techport" => techport, + "MAC" => %base_mac, + ); + continue; + } + debug!( + log, + "techport has correct IPv6 link local, starting DHCP agent"; + "techport" => techport + ); + start_dhcpv6_agent_impl(log, techport).await + } +} + +/// Actually spawn the DHCP agent via `ifconfig`. +async fn start_dhcpv6_agent_impl(log: &Logger, techport: &str) { + match tokio::process::Command::new(IFCONFIG) + .env_clear() + .arg(techport) + .arg("inet6") + .arg("dhcp") + .arg("wait") + .arg("0") + .arg("start") + .output() + .await + { + Ok(out) if dhcp_is_now_running(&out) => { + debug!( + log, + "DHCP started or already running for techport"; + "techport" => techport, + ); + } + Ok(out) => { + error!( + log, + "`ifconfig` process returned an error"; + "exit_status" => %out.status, + "stderr" => String::from_utf8_lossy(&out.stderr), + "techport" => techport, + ); + } + Err(e) => { + error!( + log, + "failed to spawn or wait for `ifconfig` command"; + "error" => %e, + "techport" => techport, + ); + } + } +} + +/// Check the output of `ifconfig` to see if agent is now running. +/// +/// This handles the agent being newly started or already running on the +/// interface. +fn dhcp_is_now_running(out: &Output) -> bool { + if out.status.success() { + return true; + } + if out.status.code() != Some(DHCP_EXIT_FAILURE) { + return false; + } + out.stderr.ends_with(DHCP_IS_ALREADY_RUNNING_MSG) + || out.stderr.ends_with(DHCP_HAS_PENDING_COMMAND) +} + +/// Return true if the techport has the IPv6 link-local address derived from the +/// provided MAC address. +async fn has_correct_ipv6_link_local( + log: &Logger, + techport: &str, + base_mac: &MacAddr, +) -> bool { + let out = match tokio::process::Command::new(IPADM) + .env_clear() + .arg("show-addr") + .arg(format!("{techport}/{IPV6_LINK_LOCAL_NAME}")) + .arg("-p") + .arg("-o") + .arg("ADDR") + .output() + .await + { + Ok(out) if out.status.success() => out, + Ok(out) => { + error!( + log, + "`ipadm` process returned an error"; + "exit_status" => %out.status, + "stderr" => String::from_utf8_lossy(&out.stderr), + "techport" => techport, + ); + return false; + } + Err(e) => { + error!( + log, + "failed to spawn or wait for `ipadm` command"; + "error" => %e, + ); + return false; + } + }; + let Ok(stdout) = std::str::from_utf8(&out.stdout) else { + error!( + log, + "`ipadm` process returned non-UTF8 stdout!"; + "stdout_lossy" => String::from_utf8_lossy(&out.stdout) + ); + return false; + }; + stdout.lines().any(|line| has_matching_ipv6_link_local(line, base_mac)) +} + +/// Return true if the provided line from `ipadm` output shows an IPv6 +/// link-local address derived from the provided MAC address. +fn has_matching_ipv6_link_local(line: &str, base_mac: &MacAddr) -> bool { + let expected_link_local = generate_ipv6_link_local(*base_mac); + let Some((prefix, _rest)) = line.split_once("%") else { + return false; + }; + let Ok(actual_addr) = prefix.parse::() else { + return false; + }; + expected_link_local == actual_addr +} + +/// Ensure our DHCPv6 Unique Identifier (DUID) is written persistently to disk. +/// +/// Some deployments run DHCPv6 over our technician ports. In that protocol, +/// clients identify themselves with a DUID, which is supposed to be a stable, +/// unique identifier so that servers can assign consistent configuration data +/// to the client. By default illumos's `dhcpagent` uses the Link-Layer Address +/// Plus Time option for this ID. However both the time and MAC address can +/// change, violating the stability requirement. The latter changes because the +/// switch zone is assigned a VNIC as its "first" datalink from the sled-agent +/// in the global zone. That VNIC has a random locally-administered prefix, +/// 02:08:20:... +/// +/// Once the `dhcpagent` has a DUID, it persists it to a file and reads that +/// whenever starting a new exchange. In the Oxide product, we're ensuring this +/// file contains our expected, stable ID, based on the MAC address stored in +/// the switch's SP FRUID EEPROM. +async fn ensure_duid_file_exists( + log: &Logger, + base_mac: &MacAddr, +) -> anyhow::Result<()> { + // If we've already written the file, no need to do it again. + match tokio::fs::try_exists(DUID_PATH).await { + Err(e) => anyhow::bail!("could not check for DUID file: {}", e), + Ok(true) => { + debug!(log, "DUID file already written, returning"); + return Ok(()); + } + Ok(false) => { + debug!(log, "DUID file does not exist, writing"); + } + } + write_duid_file_once(log, base_mac).await +} + +/// Atomically write the DUID file once. +async fn write_duid_file_once( + log: &Logger, + base_mac: &MacAddr, +) -> anyhow::Result<()> { + // Write the DUID into a temporary file. Note that this needs to be on the + // same filesystem as the real one, or the rename will fail. + // + // This creates the file if needed, and replaces the entire contents if + // it already exists. + let bytes = super::create_duid_bytes(base_mac); + match tokio::fs::write(&TEMP_DUID_PATH, &bytes).await { + Ok(_) => debug!(log, "wrote DUID to tempfile"), + Err(e) => { + anyhow::bail!( + "failed to write DUID to tempfile '{}': {}", + TEMP_DUID_PATH, + e, + ); + } + } + + // Atomically swap it into place. + match tokio::fs::rename(&TEMP_DUID_PATH, DUID_PATH).await { + Ok(_) => { + info!( + log, + "wrote stable DHCPv6 DUID"; + "path" => DUID_PATH, + "MAC" => base_mac.as_slice(), + ); + Ok(()) + } + Err(e) => { + anyhow::bail!( + "failed to rename DUID temp file '{}' to \ + real path '{}': {}", + TEMP_DUID_PATH, + DUID_PATH, + e, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_matching_ipv6_link_local() { + let mac = MacAddr::new(0xa8, 0x40, 0x25, 0x01, 0x02, 0x03); + let line = "fe80::aa40:25ff:fe01:203%techport0/10"; + assert!(has_matching_ipv6_link_local(line, &mac)); + + // Nearly the right address, but without the local bit. + let line = "fe80::a840:25ff:fe01:203%techport0/10"; + assert!(!has_matching_ipv6_link_local(line, &mac)); + + // No scope ID + let line = "fe80::aa40:25ff:fe01:203"; + assert!(!has_matching_ipv6_link_local(line, &mac)); + + // Not a link-local at all + let line = "2001::aa40:25ff:fe01:203%techport0/10"; + assert!(!has_matching_ipv6_link_local(line, &mac)); + } + + #[test] + fn test_dhcp_is_now_running() { + let success = std::process::Command::new("true") + .env_clear() + .output() + .expect("Failed to spawn `true`"); + assert!(success.status.success()); + + let successful = + Output { status: success.status, stdout: vec![], stderr: vec![] }; + assert!(dhcp_is_now_running(&successful)); + + let exit_2 = std::process::Command::new("/bin/bash") + .env_clear() + .arg("-c") + .arg("exit 2") + .output() + .expect("Failed to spawn `bash`"); + assert!(!exit_2.status.success()); + assert_eq!(exit_2.status.code(), Some(2)); + + let already_running = Output { + status: exit_2.status, + stdout: vec![], + stderr: b"ifconfig: ixgbe0: DHCP is already running".to_vec(), + }; + assert!(dhcp_is_now_running(&already_running)); + + let failure = std::process::Command::new("false") + .env_clear() + .output() + .expect("Failed to spawn `false`"); + assert!(!failure.status.success()); + + let wrong_exit_code = Output { + status: failure.status, + stdout: vec![], + stderr: b"ifconfig: ixgbe0: DHCP is already running".to_vec(), + }; + assert!(!dhcp_is_now_running(&wrong_exit_code)); + + let wrong_msg = Output { + status: exit_2.status, + stdout: vec![], + stderr: b"ifconfig: ixgbe0: bad address".to_vec(), + }; + assert!(!dhcp_is_now_running(&wrong_msg)); + } +} diff --git a/dpd/src/macaddrs.rs b/dpd/src/macaddrs.rs index 7acff4e6..f526098b 100644 --- a/dpd/src/macaddrs.rs +++ b/dpd/src/macaddrs.rs @@ -371,27 +371,31 @@ impl Switch { } } - // We may start `dpd` without a base MAC address for assigning addresses to - // links. In that situation, we need to fetch the base MAC from the Sidecar - // SP via the management network. This presents us with a bootstrapping - // problem: we need a MAC to bring up that link, via which we'd fetch the - // MACs. To break the circularity, we will use a random MAC to temporarily - // bring up the CPU link; fetch the real MAC addresses; tear down the CPU - // link; and the continue as normal. We'll cache that as an SMF property to - // avoid the complexity each time we restart. - // - // In general, our process is: - // - // - Create a link on the CPU port, using a random MAC address. - // - Create a transceiver controller. This _will block_ until tfportd makes - // us the `sidecar0` VLAN interface that we're expecting to use. - // - Fetch the MAC address range from the SP using the controller. - // - Update the MAC address on the CPU link with the final address derived - // from the base_mac + /// Set the base MAC address for Dendrite. + /// + /// We may start `dpd` without a base MAC address for assigning addresses to + /// links. In that situation, we need to fetch the base MAC from the Sidecar + /// SP via the management network. This presents us with a bootstrapping + /// problem: we need a MAC to bring up that link, via which we'd fetch the + /// MACs. To break the circularity, we will use a random MAC to temporarily + /// bring up the CPU link; fetch the real MAC addresses; tear down the CPU + /// link; and the continue as normal. We'll cache that as an SMF property to + /// avoid the complexity each time we restart. + /// + /// In general, our process is: + /// + /// - Create a link on the CPU port, using a random MAC address. + /// - Create a transceiver controller. This _will block_ until tfportd makes + /// us the `sidecar0` VLAN interface that we're expecting to use. + /// - Fetch the MAC address range from the SP using the controller. + /// - Update the MAC address on the CPU link with the final address derived + /// from the base_mac + /// + /// This returns the base MAC address in any case. pub async fn set_base_mac_address( &self, autoconfig_links: &Option, - ) -> anyhow::Result { + ) -> anyhow::Result { slog::info!( self.log, "no base MAC address found, fetching from Sidecar FRUID" @@ -472,19 +476,22 @@ impl Switch { .expect("Expected a MAC for the internal CPU link"); self.set_link_mac_address(port_id, link_id, cpu_mac)?; - Ok(true) + Ok(base_mac) } } #[cfg(not(feature = "tofino_asic"))] impl Switch { /// Assign a permanent but random base MAC address on non-Tofino systems. + /// + /// Return the MAC address. pub async fn set_base_mac_address( &self, _autoconfig_links: &Option, - ) -> anyhow::Result { + ) -> anyhow::Result { // For non-ASIC builds, we just use a random MAC as our base address. - let base_mac = BaseMac::Permanent(MacAddr::random_oxide()); + let mac = MacAddr::random_oxide(); + let base_mac = BaseMac::Permanent(mac); debug!( self.log, "assigning random base MAC address"; @@ -492,7 +499,7 @@ impl Switch { ); let mut mgr = self.mac_mgmt.lock().unwrap(); mgr.set_base_mac(base_mac)?; - Ok(false) + Ok(mac) } } diff --git a/dpd/src/main.rs b/dpd/src/main.rs index a2b37ddc..a55df82d 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -59,6 +59,7 @@ mod arp; mod attached_subnet; mod config; mod counters; +mod dhcpv6; mod fault; mod freemap; mod link; @@ -411,9 +412,11 @@ impl Switch { entries: t .get_entries::(&self.asic_hdl, from_hardware) .map_err(|e| { - error!(self.log, "failed to get table contents"; - "table" => t.type_.to_string(), - "error" => %e); + error!( + self.log, "failed to get table contents"; + "table" => t.type_.to_string(), + "error" => %e + ); e }) .map(|vec| { @@ -436,9 +439,11 @@ impl Switch { t.get_counters::(&self.asic_hdl, force_sync) .map_err(|e| { - error!(self.log, "failed to get counter data"; - "table" => t.type_.to_string(), - "error" => %e); + error!( + self.log, "failed to get counter data"; + "table" => t.type_.to_string(), + "error" => %e + ); e }) .map(|vec| { @@ -701,9 +706,9 @@ async fn sidecar_main(mut switch: Switch) -> anyhow::Result<()> { // If there has been no base mac address configured via SMF or the command // line, then we need to fetch it from the SP (for real sidecars) or just // make one up (everywhere else). - let skip_cpu_link = match config_base_mac { - Some(base_mac) => { - let base_mac = BaseMac::Permanent(base_mac); + let (skip_cpu_link, base_mac) = match config_base_mac { + Some(config_mac) => { + let base_mac = BaseMac::Permanent(config_mac); debug!( switch.log, "permanent base MAC address already set, it will be kept"; @@ -711,11 +716,20 @@ async fn sidecar_main(mut switch: Switch) -> anyhow::Result<()> { ); let mut mgr = switch.mac_mgmt.lock().unwrap(); assert_eq!(mgr.set_base_mac(base_mac)?, None); - false + (false, config_mac) + } + None => { + let base_mac = + switch.set_base_mac_address(&autoconfig_links).await?; + (true, base_mac) } - None => switch.set_base_mac_address(&autoconfig_links).await?, }; + // Start the task managing DHCPv6 addresses on the technician ports. + let dhcpv6_log = switch.log.new(slog::o!("unit" => "dhcpv6-task")); + let dhcpv6_task = + tokio::task::spawn(dhcpv6::ensure_dhcpv6_agent(dhcpv6_log, base_mac)); + if let Some(auto_conf) = &autoconfig_links { // If we've created the link on the CPU port above, to fetch the MAC // addresses from the Sidecar, then we skip that particular link in this @@ -780,6 +794,7 @@ async fn sidecar_main(mut switch: Switch) -> anyhow::Result<()> { api_server_manager .await .expect("while shutting down the api_server_manager"); + dhcpv6_task.await?; info!(switch.log, "shutting down switch driver"); switch.asic_hdl.fini(); @@ -788,7 +803,6 @@ async fn sidecar_main(mut switch: Switch) -> anyhow::Result<()> { Ok(()) } - fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -802,7 +816,11 @@ async fn run_dpd(opt: Opt) -> anyhow::Result<()> { let log = common::logging::init("dpd", &config.log_file, config.log_format)?; - info!(log, "dpd config: {config:#?}"); + info!( + log, "starting dpd"; + "config" => ?config, + "build_info" => ?api_server::build_info(), + ); let p4_name = std::env::var("P4_NAME").unwrap_or_else(|_| String::from("sidecar")); diff --git a/tfportd/Cargo.toml b/tfportd/Cargo.toml index 06476957..5ef214ed 100644 --- a/tfportd/Cargo.toml +++ b/tfportd/Cargo.toml @@ -15,6 +15,7 @@ chrono.workspace = true csv.workspace = true futures.workspace = true http.workspace = true +iddqd.workspace = true internet-checksum.workspace = true ispf.workspace = true libc.workspace = true diff --git a/tfportd/src/linklocal.rs b/tfportd/src/linklocal.rs index 4b61b242..2ebc419c 100644 --- a/tfportd/src/linklocal.rs +++ b/tfportd/src/linklocal.rs @@ -10,14 +10,11 @@ use std::net::Ipv6Addr; use anyhow::Result; use anyhow::anyhow; use slog::debug; +use slog::error; use crate::Global; use common::illumos; - -/// The suffix for the addrobj name for IPv6 link-local addresses on each tfport. -/// -/// E.g., all IPv6 addresses are named like `tfportrear0_0/ll`. -const IPV6_LINK_LOCAL_NAME: &str = "ll"; +use common::illumos::IPV6_LINK_LOCAL_NAME; // Parse a single line of ipadm output to extract the addrobj name and link-local // address. This function returns an error if the ipadm command fails or the @@ -77,10 +74,13 @@ pub async fn get_all() -> Result> { } // Create a link-local address for an interface +// +// TODO-cleanup: This function is actually infallible. We should either +// propagate the error or actually reflect its infallibility in the return type. pub async fn create(g: &Global, iface: &str) -> anyhow::Result<()> { debug!(g.log, "creating link-local address for {iface}"); if let Err(e) = illumos::linklocal_add(iface, IPV6_LINK_LOCAL_NAME).await { - slog::error!(g.log, "failed to create link-local address: {e:?}"); + error!(g.log, "failed to create link-local address: {e:?}"); } Ok(()) } diff --git a/tfportd/src/ports.rs b/tfportd/src/ports.rs index c300fa1a..8005d598 100644 --- a/tfportd/src/ports.rs +++ b/tfportd/src/ports.rs @@ -134,17 +134,21 @@ async fn dpd_port_update(g: &Global, links: &mut LinkMap) -> Result<()> { // Any change here should cause a mismatch when looking at the existing // tfports. if link.mac != entry_mac { - warn!(g.log, "tfport changed mac addresses"; - "tfport" => &expected_tfport, - "mac" => entry_mac.to_string(), - "stale_mac" => link.mac.to_string()); + warn!( + g.log, "tfport changed mac addresses"; + "tfport" => &expected_tfport, + "mac" => entry_mac.to_string(), + "stale_mac" => link.mac.to_string() + ); link.mac = entry_mac; } if link.asic_id != entry.asic_id { - warn!(g.log, "tfport changed asic IDs"; - "tfport" => &expected_tfport, - "asic_id" => entry.asic_id, - "stale_asic_id" => link.asic_id); + warn!( + g.log, "tfport changed asic IDs"; + "tfport" => &expected_tfport, + "asic_id" => entry.asic_id, + "stale_asic_id" => link.asic_id + ); link.asic_id = entry.asic_id; } } @@ -184,18 +188,19 @@ async fn illumos_port_update( link.tfport_link_local = data.link_local; continue; } else { - info!(g.log, "tfport found with stale data"; - "tfport" => tfport, - "mac" => link.mac.to_string(), - "stale_mac" => data.mac.to_string(), - "asic_id" => link.asic_id, - "stale_asic_id" => data.port); + info!( + g.log, "tfport found with stale data"; + "tfport" => tfport, + "mac" => link.mac.to_string(), + "stale_mac" => data.mac.to_string(), + "asic_id" => link.asic_id, + "stale_asic_id" => data.port + ); } } None => { if link.tfport.is_some() { - info!(g.log, "tfport disappeared"; - "tfport" => tfport); + info!(g.log, "tfport disappeared"; "tfport" => tfport); } } } @@ -282,9 +287,11 @@ pub async fn port_loop(g: Arc) { // Clean up any orphaned tfports for tfport in orphans { if let Err(e) = tfport::tfport_delete(&g, &tfport).await { - error!(g.log, - "failed to clean up stale tfport: {e:?}"; - "tfport" => tfport) + error!( + g.log, + "failed to clean up stale tfport: {e:?}"; + "tfport" => tfport + ); } } @@ -300,15 +307,19 @@ pub async fn port_loop(g: Arc) { } if let Err(e) = tfport::tfport_ensure(&g, tfport, link).await { - error!(g.log, - "tfport_ensure() failed: {e:?}"; - "tfport" => tfport) + error!( + g.log, + "tfport_ensure() failed: {e:?}"; + "tfport" => tfport + ); } if let Err(e) = ensure_address_match(&g, link).await { - error!(g.log, - "ensure_address_match() failed: {e:?}"; - "tfport" => tfport) + error!( + g.log, + "ensure_address_match() failed: {e:?}"; + "tfport" => tfport + ); } } *g.tfport_to_asic.lock().unwrap() = tfport_to_asic; diff --git a/tfportd/src/vlans.rs b/tfportd/src/vlans.rs index 6aac5f8d..7dbea3d3 100644 --- a/tfportd/src/vlans.rs +++ b/tfportd/src/vlans.rs @@ -11,19 +11,27 @@ use std::net::Ipv6Addr; use anyhow::Context; use anyhow::anyhow; use anyhow::bail; +use iddqd::IdOrdItem; +use iddqd::IdOrdMap; +use iddqd::id_upcast; use serde::Deserialize; use slog::error; use slog::info; +use slog::warn; use crate::Global; use crate::linklocal; use crate::oxstats::link; use common::illumos; +/// An entry in the `port_map.csv` file provided at program startup. #[derive(Debug, Deserialize)] struct PortMapEntry { + /// The VSC7448 port number. port: u16, + /// A human-friendly string naming the logical partner on the link. _link_partner: String, + /// The name of the VLAN object to be created mapping to the link partner. vlan_name: String, } @@ -34,9 +42,11 @@ pub struct Vlan { pub name: String, } -/// The information illumos maintains about a single vlan +/// The information illumos maintains about a single VLAN #[derive(Debug)] pub struct VlanInfo { + /// The name of the VLAN device. + pub name: String, /// VLAN ID pub vid: u16, /// index of the interface created by `ipadm` @@ -45,18 +55,28 @@ pub struct VlanInfo { pub link_local: Option, } +impl IdOrdItem for VlanInfo { + type Key<'a> = &'a str; + + fn key(&self) -> Self::Key<'_> { + &self.name + } + + id_upcast!(); +} + /// Get the list of vlans created on top of a tfport -async fn vlans_get(tfport: &str) -> anyhow::Result> { +async fn vlans_get(tfport: &str) -> anyhow::Result> { let link_locals = linklocal::get_all().await?; let lines = illumos::dladm(&["show-vlan", "-p", "-o", "link,vid,over"]).await?; // Iterate over the dladm output, extracting the vlan name and vid from each // line. For each vlan created on top of this tfport, add an entry to the - // BTreeMap with the network configuration for each one. - let mut rval = BTreeMap::new(); + // map with the network configuration for each one. + let mut rval = IdOrdMap::new(); for vlan in lines { - let fields: Vec = vlan.split(':').map(str::to_string).collect(); + let fields: Vec<_> = vlan.split(':').collect(); if fields.len() != 3 { bail!("show-vlan returned invalid result: {vlan}"); } @@ -68,7 +88,11 @@ async fn vlans_get(tfport: &str) -> anyhow::Result> { let vid = fields[1].parse::().context("invalid vlan_id")?; let ifindex = crate::netsupport::get_ifindex(&link); let link_local = link_locals.get(&link).copied(); - rval.insert(link, VlanInfo { vid, ifindex, link_local }); + let vlan = VlanInfo { name: link, vid, ifindex, link_local }; + + // NOTE: We previously used a BTreeMap here, and ignored any duplicates. + // Keep the same behavior, ignoring the error. + let _ = rval.insert_overwrite(vlan); } Ok(rval) } @@ -92,7 +116,8 @@ pub async fn vlans_cleanup(g: &Global, tfport: &str) -> anyhow::Result<()> { .await .map_err(|e| anyhow!("failed to get vlan list for {tfport}: {e:?}"))?; - for (name, vlan) in &vlans { + for vlan in &vlans { + let name = &vlan.name; let vid = vlan.vid; match vlan_delete(g, name).await { Ok(_) => info!(g.log, "deleted vlan {vid}:{name} on {tfport}"), @@ -117,14 +142,17 @@ pub async fn ensure_vlans(g: &Global, link: &str) -> anyhow::Result<()> { // links that should be created and/or deleted. We start by pessimistically // assuming that each vlan needs to be deleted, removing them from the // to_delete list if we find them on the "expected" list below. - let mut to_delete = - existing_vlans.keys().cloned().collect::>(); + let mut to_delete = existing_vlans + .iter() + .map(|vlan| vlan.name.to_string()) + .collect::>(); for expected_vlan in &g.vlans { - if let Some(current_vlan) = existing_vlans.get(&expected_vlan.name) + if let Some(current_vlan) = + existing_vlans.get(expected_vlan.name.as_str()) && current_vlan.vid == expected_vlan.vid { // This vlan has the right name and ID, so we leave it alone - let _ = to_delete.remove(&expected_vlan.name); + let _ = to_delete.remove(expected_vlan.name.as_str()); continue; } to_create.insert(expected_vlan.name.to_string(), expected_vlan.vid); @@ -135,7 +163,7 @@ pub async fn ensure_vlans(g: &Global, link: &str) -> anyhow::Result<()> { if let Err(e) = vlan_delete(g, name).await { error!(g.log, "failed to delete vlan {name}: {e:?}"); } - let _ = existing_vlans.remove(name); + let _ = existing_vlans.remove(name.as_str()); } // Create any missing vlans @@ -143,16 +171,28 @@ pub async fn ensure_vlans(g: &Global, link: &str) -> anyhow::Result<()> { match illumos::vlan_create(link, vid, &name).await { Ok(()) => { info!(g.log, "created vlan {vid}:{name} on {link}"); - existing_vlans.insert( - name.to_string(), - VlanInfo { vid, ifindex: None, link_local: None }, - ); + let vlan = VlanInfo { + name: name.clone(), + vid, + ifindex: None, + link_local: None, + }; + // NOTE: We previously used a BTreeMap here, and ignored any duplicates. + // Keep the same behavior, logging an error. + if let Some(old) = existing_vlans.insert_overwrite(vlan) { + warn!( + &g.log, + "overwriting duplicate VLAN for tfport"; + "tfport" => link, + "vlan" => old.name, + "vid" => old.vid, + ); + } // Once the vlan is created, we can track it as a potential // network link. - if let Err(e) = g - .link_tracker - .track_link(name.to_string(), link::ModelType::Vlan) + if let Err(e) = + g.link_tracker.track_link(&name, link::ModelType::Vlan) { error!(g.log, "failed to track vlan {name}: {e:?}"); } @@ -165,39 +205,36 @@ pub async fn ensure_vlans(g: &Global, link: &str) -> anyhow::Result<()> { // Iterate over all of the vlans (old and new) and ensure that they have a // link-local address. - for (name, info) in existing_vlans.iter_mut() { + for mut info in existing_vlans.iter_mut() { // If the interface exists but the address doesn't, we need to remove the // interface before creating the link-local address due to stlouis#531. if info.link_local.is_none() && info.ifindex.is_some() - && illumos::iface_remove(name).await.is_ok() + && illumos::iface_remove(&info.name).await.is_ok() { info.ifindex = None; } if info.ifindex.is_none() { - match illumos::iface_ensure(name).await { + match illumos::iface_ensure(&info.name).await { Ok(()) => { - slog::debug!(g.log, "created interface for vlan: {name}") + slog::debug!( + g.log, + "created interface for vlan: {}", + &info.name + ) } Err(e) => { slog::error!( g.log, - "failed to create interface for vlan: {name}: {e}" + "failed to create interface for vlan: {}: {e}", + &info.name, ); continue; } } } if info.link_local.is_none() { - match linklocal::create(g, name).await { - Ok(()) => { - slog::debug!(g.log, "created link-local for vlan: {name}") - } - Err(e) => slog::error!( - g.log, - "failed to create link-local for vlan: {name}: {e}" - ), - } + let _ = linklocal::create(g, &info.name).await; } } diff --git a/tools/omicron-asic-manifest.toml b/tools/omicron-asic-manifest.toml index 01b7231d..8bc68a2b 100644 --- a/tools/omicron-asic-manifest.toml +++ b/tools/omicron-asic-manifest.toml @@ -23,6 +23,7 @@ source.paths = [ {from = "target/proto/opt/oxide/tofino_sde/lib/libtarget_utils.so" , to = "/opt/oxide/tofino_sde/lib/libtarget_utils.so"}, {from = "target/proto/opt/oxide/tofino_sde/lib/libclish.so" , to = "/opt/oxide/tofino_sde/lib/libclish.so"}, {from = "target/proto/opt/oxide/dendrite/sidecar/share/platforms/board-maps/oxide/" , to = "/opt/oxide/dendrite/sidecar/share/platforms/board-maps/oxide/"}, - {from = "target/proto/opt/oxide/dendrite/sidecar/share/cli/xml" , to = "/opt/oxide/dendrite/sidecar/share/cli/xml"} + {from = "target/proto/opt/oxide/dendrite/sidecar/share/cli/xml" , to = "/opt/oxide/dendrite/sidecar/share/cli/xml"}, + {from = "dpd/misc/ndpd.conf" , to = "/etc/inet/ndpd.conf"} ] output.type = "zone" diff --git a/uplinkd/src/main.rs b/uplinkd/src/main.rs index 7ac1d757..08d3c83a 100644 --- a/uplinkd/src/main.rs +++ b/uplinkd/src/main.rs @@ -40,6 +40,7 @@ use anyhow::Result; use anyhow::anyhow; use clap::Parser; use common::illumos::AddressFamily; +use common::illumos::IPV6_LINK_LOCAL_NAME; use libc::c_int; use oxnet::IpNet; use oxnet::Ipv4Net; @@ -413,9 +414,11 @@ async fn create_link(iface: &str, tag: &str, addr: &IpNet) -> Result { // Add a link-local address using ipadm async fn create_linklocal(iface: &str) -> Result { - illumos::linklocal_add(iface, "ll") + illumos::linklocal_add(iface, IPV6_LINK_LOCAL_NAME) .await - .map(|_| format!("created link-local as {iface}/ll")) + .map(|_| { + format!("created link-local as {iface}/{IPV6_LINK_LOCAL_NAME}") + }) .map_err(|e| e.into()) } diff --git a/xtask/src/codegen.rs b/xtask/src/codegen.rs index a89078a7..87fedc8a 100644 --- a/xtask/src/codegen.rs +++ b/xtask/src/codegen.rs @@ -175,7 +175,10 @@ pub fn build( args.push(app_path); println!("op: {args:?}"); - let out = Command::new(&p4c_path).args(&args).output()?; + let out = Command::new(&p4c_path) + .args(&args) + .output() + .with_context(|| format!("opening '{p4c_path}'"))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); if !out.status.success() { From de2a1248fcc8712ec472d7d6ce15d6a9e6ccce3c Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Wed, 20 May 2026 00:54:55 +0000 Subject: [PATCH 2/5] Update SDE temporarily, use existing shell wrappers --- .github/buildomat/common.sh | 6 +- dpd/src/dhcpv6/illumos.rs | 158 +++++++++--------------------------- 2 files changed, 43 insertions(+), 121 deletions(-) diff --git a/.github/buildomat/common.sh b/.github/buildomat/common.sh index f8df2081..605f297e 100644 --- a/.github/buildomat/common.sh +++ b/.github/buildomat/common.sh @@ -11,9 +11,9 @@ TOFINO_STAGES=20 # These describe which version of the SDE to download and where to find it -SDE_COMMIT=2a6b33211c9675996dcb99fe939045506667ae94 -SDE_PKG_SHA256=d32739c368d1666b98dd74e25e22f83c209982e2c6670de6db5d6fdf49b5e275 -SDE_DEB_SHA256=3ecbf7c677bb722b351d5af74cee44fab70c1bb5eadc6ab2558ba714a8c3978b +SDE_COMMIT=0af3643d410b7d06fce709b066287ce14e2e5b3d +SDE_PKG_SHA256=5060910618848a8f6c93fb5ecf3b9a46b2d4d6b1ee6c0db5339e5732d1adfd4d +SDE_DEB_SHA256=17a5bdc0e3ecda6f6c525b338a63f25e171cfa6c0aee745b8115de87832209aa [ `uname -s` == "SunOS" ] && SERIES=illumos [ `uname -s` == "SunOS" ] || SERIES=linux diff --git a/dpd/src/dhcpv6/illumos.rs b/dpd/src/dhcpv6/illumos.rs index 3d715380..db44f455 100644 --- a/dpd/src/dhcpv6/illumos.rs +++ b/dpd/src/dhcpv6/illumos.rs @@ -4,6 +4,9 @@ // // Copyright 2026 Oxide Computer Company +use common::illumos::ifconfig; +use common::illumos::ipadm; +use common::illumos::IllumosError; use common::illumos::IPV6_LINK_LOCAL_NAME; use common::network::MacAddr; use common::network::generate_ipv6_link_local; @@ -13,24 +16,18 @@ use slog::error; use slog::info; use slog::warn; use std::net::Ipv6Addr; -use std::process::Output; use std::time::Duration; -const IPADM: &str = "/usr/sbin/ipadm"; -const IFCONFIG: &str = "/usr/sbin/ifconfig"; const DUID_PATH: &str = "/etc/dhcp/duid"; const TEMP_DUID_PATH: &str = "/etc/dhcp/duid.temp"; const TECHPORTS: [&str; 2] = ["techport0", "techport1"]; -// From illumos source: `usr/include/dhcpagent_ipc.h:62` -const DHCP_EXIT_FAILURE: i32 = 2; - // From illumos source: `usr/include/dhcpagent_ipc.h:649` -const DHCP_IS_ALREADY_RUNNING_MSG: &[u8] = b"DHCP is already running"; +const DHCP_IS_ALREADY_RUNNING_MSG: &str = "DHCP is already running"; // From illumos source: `usr/include/dhcpagent_ipc.h:606` -const DHCP_HAS_PENDING_COMMAND: &[u8] = - b"interface curently has a pending command (try later)"; +const DHCP_HAS_PENDING_COMMAND: &str = + "interface curently has a pending command (try later)"; /// Ensure that the DHCP agent is running on the techports, checking that they /// have the provided base MAC address. @@ -74,37 +71,33 @@ async fn start_dhcpv6_agent(log: &Logger, base_mac: &MacAddr) { /// Actually spawn the DHCP agent via `ifconfig`. async fn start_dhcpv6_agent_impl(log: &Logger, techport: &str) { - match tokio::process::Command::new(IFCONFIG) - .env_clear() - .arg(techport) - .arg("inet6") - .arg("dhcp") - .arg("wait") - .arg("0") - .arg("start") - .output() - .await + match ifconfig(&[ + techport, + "inet6", + "dhcp", + "wait", + "0", + "start", + ]).await { - Ok(out) if dhcp_is_now_running(&out) => { + Ok(_) => { debug!( log, - "DHCP started or already running for techport"; + "DHCP started on techport"; "techport" => techport, ); } - Ok(out) => { - error!( + Err(e) if dhcp_is_already_running(&e) => { + debug!( log, - "`ifconfig` process returned an error"; - "exit_status" => %out.status, - "stderr" => String::from_utf8_lossy(&out.stderr), + "DHCP is already running for techport"; "techport" => techport, ); } Err(e) => { error!( log, - "failed to spawn or wait for `ifconfig` command"; + "failed to run `ifconfig` command"; "error" => %e, "techport" => techport, ); @@ -112,19 +105,16 @@ async fn start_dhcpv6_agent_impl(log: &Logger, techport: &str) { } } -/// Check the output of `ifconfig` to see if agent is now running. -/// -/// This handles the agent being newly started or already running on the -/// interface. -fn dhcp_is_now_running(out: &Output) -> bool { - if out.status.success() { - return true; - } - if out.status.code() != Some(DHCP_EXIT_FAILURE) { - return false; +/// Check the error message from `ifconfig` to see if agent is already running. +fn dhcp_is_already_running(e: &IllumosError) -> bool { + match e { + IllumosError::Exec(_) => false, + IllumosError::Failed(msg) => { + msg.contains(DHCP_IS_ALREADY_RUNNING_MSG) || + msg.contains(DHCP_HAS_PENDING_COMMAND) + } + IllumosError::BadOutput(_) => false, } - out.stderr.ends_with(DHCP_IS_ALREADY_RUNNING_MSG) - || out.stderr.ends_with(DHCP_HAS_PENDING_COMMAND) } /// Return true if the techport has the IPv6 link-local address derived from the @@ -134,45 +124,26 @@ async fn has_correct_ipv6_link_local( techport: &str, base_mac: &MacAddr, ) -> bool { - let out = match tokio::process::Command::new(IPADM) - .env_clear() - .arg("show-addr") - .arg(format!("{techport}/{IPV6_LINK_LOCAL_NAME}")) - .arg("-p") - .arg("-o") - .arg("ADDR") - .output() - .await + let lines = match ipadm(&[ + "show-addr", + "-p", + "-o", + "ADDR", + &format!("{techport}/{IPV6_LINK_LOCAL_NAME}"), + ]).await { - Ok(out) if out.status.success() => out, - Ok(out) => { - error!( - log, - "`ipadm` process returned an error"; - "exit_status" => %out.status, - "stderr" => String::from_utf8_lossy(&out.stderr), - "techport" => techport, - ); - return false; - } + Ok(lines) => lines, Err(e) => { error!( log, - "failed to spawn or wait for `ipadm` command"; + "failed to run `ipadm` command"; "error" => %e, + "techport" => techport, ); return false; } }; - let Ok(stdout) = std::str::from_utf8(&out.stdout) else { - error!( - log, - "`ipadm` process returned non-UTF8 stdout!"; - "stdout_lossy" => String::from_utf8_lossy(&out.stdout) - ); - return false; - }; - stdout.lines().any(|line| has_matching_ipv6_link_local(line, base_mac)) + lines.iter().any(|line| has_matching_ipv6_link_local(line, base_mac)) } /// Return true if the provided line from `ipadm` output shows an IPv6 @@ -289,53 +260,4 @@ mod tests { let line = "2001::aa40:25ff:fe01:203%techport0/10"; assert!(!has_matching_ipv6_link_local(line, &mac)); } - - #[test] - fn test_dhcp_is_now_running() { - let success = std::process::Command::new("true") - .env_clear() - .output() - .expect("Failed to spawn `true`"); - assert!(success.status.success()); - - let successful = - Output { status: success.status, stdout: vec![], stderr: vec![] }; - assert!(dhcp_is_now_running(&successful)); - - let exit_2 = std::process::Command::new("/bin/bash") - .env_clear() - .arg("-c") - .arg("exit 2") - .output() - .expect("Failed to spawn `bash`"); - assert!(!exit_2.status.success()); - assert_eq!(exit_2.status.code(), Some(2)); - - let already_running = Output { - status: exit_2.status, - stdout: vec![], - stderr: b"ifconfig: ixgbe0: DHCP is already running".to_vec(), - }; - assert!(dhcp_is_now_running(&already_running)); - - let failure = std::process::Command::new("false") - .env_clear() - .output() - .expect("Failed to spawn `false`"); - assert!(!failure.status.success()); - - let wrong_exit_code = Output { - status: failure.status, - stdout: vec![], - stderr: b"ifconfig: ixgbe0: DHCP is already running".to_vec(), - }; - assert!(!dhcp_is_now_running(&wrong_exit_code)); - - let wrong_msg = Output { - status: exit_2.status, - stdout: vec![], - stderr: b"ifconfig: ixgbe0: bad address".to_vec(), - }; - assert!(!dhcp_is_now_running(&wrong_msg)); - } } From 7d3d466e13b3ef4a21697d4274771054fc0d0df2 Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Wed, 20 May 2026 01:03:58 +0000 Subject: [PATCH 3/5] fmt --- dpd/src/dhcpv6/illumos.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/dpd/src/dhcpv6/illumos.rs b/dpd/src/dhcpv6/illumos.rs index db44f455..f490d56c 100644 --- a/dpd/src/dhcpv6/illumos.rs +++ b/dpd/src/dhcpv6/illumos.rs @@ -4,10 +4,10 @@ // // Copyright 2026 Oxide Computer Company +use common::illumos::IPV6_LINK_LOCAL_NAME; +use common::illumos::IllumosError; use common::illumos::ifconfig; use common::illumos::ipadm; -use common::illumos::IllumosError; -use common::illumos::IPV6_LINK_LOCAL_NAME; use common::network::MacAddr; use common::network::generate_ipv6_link_local; use slog::Logger; @@ -71,15 +71,7 @@ async fn start_dhcpv6_agent(log: &Logger, base_mac: &MacAddr) { /// Actually spawn the DHCP agent via `ifconfig`. async fn start_dhcpv6_agent_impl(log: &Logger, techport: &str) { - match ifconfig(&[ - techport, - "inet6", - "dhcp", - "wait", - "0", - "start", - ]).await - { + match ifconfig(&[techport, "inet6", "dhcp", "wait", "0", "start"]).await { Ok(_) => { debug!( log, @@ -110,8 +102,8 @@ fn dhcp_is_already_running(e: &IllumosError) -> bool { match e { IllumosError::Exec(_) => false, IllumosError::Failed(msg) => { - msg.contains(DHCP_IS_ALREADY_RUNNING_MSG) || - msg.contains(DHCP_HAS_PENDING_COMMAND) + msg.contains(DHCP_IS_ALREADY_RUNNING_MSG) + || msg.contains(DHCP_HAS_PENDING_COMMAND) } IllumosError::BadOutput(_) => false, } @@ -130,7 +122,8 @@ async fn has_correct_ipv6_link_local( "-o", "ADDR", &format!("{techport}/{IPV6_LINK_LOCAL_NAME}"), - ]).await + ]) + .await { Ok(lines) => lines, Err(e) => { From 5a6ebf2d032804d6d98a8199b7fb485314148842 Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Wed, 20 May 2026 06:04:29 +0000 Subject: [PATCH 4/5] Restart in.ndpd instead of triggering dhcpagent directly --- dpd/misc/ndpd.conf | 7 - dpd/src/dhcpv6.rs | 2 +- dpd/src/dhcpv6/illumos.rs | 275 ++++++++++++++++---------------------- 3 files changed, 118 insertions(+), 166 deletions(-) diff --git a/dpd/misc/ndpd.conf b/dpd/misc/ndpd.conf index f56e124e..bf302750 100644 --- a/dpd/misc/ndpd.conf +++ b/dpd/misc/ndpd.conf @@ -1,8 +1 @@ -# Do not run DHCPv6 on any interfaces by default. -# -# DHCPv6 servers rely on unique identifiers, DUIDs, to identify clients. -# Those are usually based on the link-layer address. The Omicron switch zone -# starts with a random locally-administered MAC, which means we _don't_ have a -# stable ID. Dendrite software manually creates the DUID once we've collected -# stable MAC addresses from the Sidecar SP. ifdefault StatefulAddrConf false diff --git a/dpd/src/dhcpv6.rs b/dpd/src/dhcpv6.rs index 11d607bc..74e478f6 100644 --- a/dpd/src/dhcpv6.rs +++ b/dpd/src/dhcpv6.rs @@ -48,7 +48,7 @@ use slog::Logger; /// - The client-identifier is written to disk /// - The DHCP agent is running on the technician ports. pub(crate) async fn ensure_dhcpv6_agent(log: Logger, base_mac: MacAddr) { - dhcpv6_impl::ensure_dhcpv6_agent(log, base_mac).await + dhcpv6_impl::allow_dhcpv6_on_techports(log, base_mac).await } #[cfg(any(target_os = "illumos", test))] diff --git a/dpd/src/dhcpv6/illumos.rs b/dpd/src/dhcpv6/illumos.rs index f490d56c..907773ff 100644 --- a/dpd/src/dhcpv6/illumos.rs +++ b/dpd/src/dhcpv6/illumos.rs @@ -4,152 +4,129 @@ // // Copyright 2026 Oxide Computer Company -use common::illumos::IPV6_LINK_LOCAL_NAME; -use common::illumos::IllumosError; -use common::illumos::ifconfig; -use common::illumos::ipadm; +use anyhow::Context as _; use common::network::MacAddr; -use common::network::generate_ipv6_link_local; use slog::Logger; use slog::debug; use slog::error; use slog::info; -use slog::warn; -use std::net::Ipv6Addr; +use std::ffi::CStr; +use std::ffi::CString; +use std::ffi::c_char; +use std::fmt::Write; use std::time::Duration; +/// Path of the final resulting DUID file. const DUID_PATH: &str = "/etc/dhcp/duid"; + +/// Path of the temp DUID file, so we can atomically swap it. const TEMP_DUID_PATH: &str = "/etc/dhcp/duid.temp"; + +/// Interval on which we retry the various operations we need to succeed. +const RETRY_INTERVAL: Duration = Duration::from_secs(5); + +/// List of techports we run DHCPv6 on. const TECHPORTS: [&str; 2] = ["techport0", "techport1"]; -// From illumos source: `usr/include/dhcpagent_ipc.h:649` -const DHCP_IS_ALREADY_RUNNING_MSG: &str = "DHCP is already running"; +/// Path of `in.ndpd`'s configuration file. +const NDPD_CONF_FILE: &str = "/etc/inet/ndpd.conf"; -// From illumos source: `usr/include/dhcpagent_ipc.h:606` -const DHCP_HAS_PENDING_COMMAND: &str = - "interface curently has a pending command (try later)"; +/// FMRI for the service running `in.ndpd +const NDPD_FMRI: &str = "svc:/network/routing/ndp:default"; -/// Ensure that the DHCP agent is running on the techports, checking that they -/// have the provided base MAC address. -pub async fn ensure_dhcpv6_agent(log: Logger, base_mac: MacAddr) { - const INTERVAL: Duration = Duration::from_secs(10); - loop { - info!(log, "starting DHCPv6 agent loop"; "base_mac" => %base_mac); - if let Err(e) = ensure_duid_file_exists(&log, &base_mac).await { - error!( - log, - "failed to ensure DUID file"; - "error" => %e, - ); - continue; - }; - start_dhcpv6_agent(&log, &base_mac).await; - tokio::time::sleep(INTERVAL).await; - } +#[link(name = "scf")] +unsafe extern "C" { + fn scf_error() -> i32; + fn scf_strerror(err: i32) -> *const c_char; + fn smf_refresh_instance(fmri: *const c_char) -> i32; } -/// Start the agent, if the techports have the expected IPv6 link-local address. -async fn start_dhcpv6_agent(log: &Logger, base_mac: &MacAddr) { - for techport in TECHPORTS { - if !has_correct_ipv6_link_local(log, techport, base_mac).await { - warn!( - log, - "techport does not yet have correct IPv6 link-local"; - "techport" => techport, - "MAC" => %base_mac, - ); - continue; - } - debug!( - log, - "techport has correct IPv6 link local, starting DHCP agent"; - "techport" => techport - ); - start_dhcpv6_agent_impl(log, techport).await - } +/// Ensure that DHCPv6 is allowed on the techports. +pub async fn allow_dhcpv6_on_techports(log: Logger, base_mac: MacAddr) { + // First, always ensure the DUID file is written correctly. We can do this + // with no coordination and it has to be done first. + ensure_duid_file_exists(&log, &base_mac).await; + + // Now, rewrite the the NDP configuration file to allow DHCPv6 on the two + // techport interfaces. + rewrite_ndpd_conf(&log).await; + + // Restart `in.ndpd`. + restart_ndpd(&log).await; } -/// Actually spawn the DHCP agent via `ifconfig`. -async fn start_dhcpv6_agent_impl(log: &Logger, techport: &str) { - match ifconfig(&[techport, "inet6", "dhcp", "wait", "0", "start"]).await { - Ok(_) => { - debug!( - log, - "DHCP started on techport"; - "techport" => techport, - ); - } - Err(e) if dhcp_is_already_running(&e) => { - debug!( - log, - "DHCP is already running for techport"; - "techport" => techport, - ); - } - Err(e) => { - error!( - log, - "failed to run `ifconfig` command"; - "error" => %e, - "techport" => techport, - ); - } +/// Restart `in.ndpd`. +async fn restart_ndpd(log: &Logger) { + loop { + let Err(e) = restart_ndpd_once().await else { + info!(log, "restarted `in.ndpd`"); + return; + }; + error!( + log, + "failed to start `in.ndpd`, will retry"; + "error" => %e, + ); + tokio::time::sleep(RETRY_INTERVAL).await; } } -/// Check the error message from `ifconfig` to see if agent is already running. -fn dhcp_is_already_running(e: &IllumosError) -> bool { - match e { - IllumosError::Exec(_) => false, - IllumosError::Failed(msg) => { - msg.contains(DHCP_IS_ALREADY_RUNNING_MSG) - || msg.contains(DHCP_HAS_PENDING_COMMAND) - } - IllumosError::BadOutput(_) => false, +async fn restart_ndpd_once() -> anyhow::Result<()> { + let fmri = CString::new(NDPD_FMRI).context("creating CString")?; + let fmri_ptr = fmri.as_c_str().as_ptr(); + let ret = unsafe { smf_refresh_instance(fmri_ptr) }; + if ret == 0 { + Ok(()) + } else { + let err = unsafe { scf_error() }; + let msg = unsafe { scf_strerror(err) }; + let msg = unsafe { CStr::from_ptr(msg) }; + anyhow::bail!( + "failed to refresh SMF instance: {}", + msg.to_string_lossy() + ) } } -/// Return true if the techport has the IPv6 link-local address derived from the -/// provided MAC address. -async fn has_correct_ipv6_link_local( - log: &Logger, - techport: &str, - base_mac: &MacAddr, -) -> bool { - let lines = match ipadm(&[ - "show-addr", - "-p", - "-o", - "ADDR", - &format!("{techport}/{IPV6_LINK_LOCAL_NAME}"), - ]) - .await - { - Ok(lines) => lines, - Err(e) => { - error!( +/// Write out new lines to the NDP configuration file allowing DHCPv6. +async fn rewrite_ndpd_conf(log: &Logger) { + loop { + let Err(e) = rewrite_ndpd_conf_once().await else { + info!( log, - "failed to run `ipadm` command"; - "error" => %e, - "techport" => techport, + "updated in.ndpd configuration file"; + "path" => NDPD_CONF_FILE, ); - return false; - } - }; - lines.iter().any(|line| has_matching_ipv6_link_local(line, base_mac)) + return; + }; + error!( + log, + "failed to update in.ndpd configuration file, will retry"; + "path" => NDPD_CONF_FILE, + "error" => %e, + ); + tokio::time::sleep(RETRY_INTERVAL).await; + } } -/// Return true if the provided line from `ipadm` output shows an IPv6 -/// link-local address derived from the provided MAC address. -fn has_matching_ipv6_link_local(line: &str, base_mac: &MacAddr) -> bool { - let expected_link_local = generate_ipv6_link_local(*base_mac); - let Some((prefix, _rest)) = line.split_once("%") else { - return false; - }; - let Ok(actual_addr) = prefix.parse::() else { - return false; - }; - expected_link_local == actual_addr +async fn rewrite_ndpd_conf_once() -> anyhow::Result<()> { + // NOTE: We completely replace the file. + // + // It might be safer to write only the parts we need, but it's very hard to + // ensure that we do that correctly, e.g., if there are partial writes. The + // cost is that this will be super confusing if we ever have more content in + // the `ndpd.conf` we ship with the switch zone. Those contents will be + // overwritten here. + let mut content = String::from("ifdefault StatefulAddrConf false\n"); + for techport in TECHPORTS { + writeln!(&mut content, "if {techport} StatefulAddrConf true") + .with_context(|| { + format!("writing if line for techport {techport}") + })?; + } + tokio::fs::write(NDPD_CONF_FILE, content) + .await + .context("writing in.ndpd conf file") } /// Ensure our DHCPv6 Unique Identifier (DUID) is written persistently to disk. @@ -168,22 +145,28 @@ fn has_matching_ipv6_link_local(line: &str, base_mac: &MacAddr) -> bool { /// whenever starting a new exchange. In the Oxide product, we're ensuring this /// file contains our expected, stable ID, based on the MAC address stored in /// the switch's SP FRUID EEPROM. -async fn ensure_duid_file_exists( - log: &Logger, - base_mac: &MacAddr, -) -> anyhow::Result<()> { - // If we've already written the file, no need to do it again. - match tokio::fs::try_exists(DUID_PATH).await { - Err(e) => anyhow::bail!("could not check for DUID file: {}", e), - Ok(true) => { - debug!(log, "DUID file already written, returning"); - return Ok(()); - } - Ok(false) => { - debug!(log, "DUID file does not exist, writing"); - } +async fn ensure_duid_file_exists(log: &Logger, base_mac: &MacAddr) { + // Always overwrite the file. There's no harm to doing so, and it avoids + // potential races when we restart. + loop { + let Err(e) = write_duid_file_once(log, base_mac).await else { + info!( + log, + "wrote DUID based on MAC to disk"; + "path" => DUID_PATH, + "MAC" => %base_mac, + ); + return; + }; + error!( + log, + "failed to write DUID to disk, will retry"; + "path" => DUID_PATH, + "MAC" => %base_mac, + "error" => %e, + ); + tokio::time::sleep(RETRY_INTERVAL).await; } - write_duid_file_once(log, base_mac).await } /// Atomically write the DUID file once. @@ -230,27 +213,3 @@ async fn write_duid_file_once( } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_has_matching_ipv6_link_local() { - let mac = MacAddr::new(0xa8, 0x40, 0x25, 0x01, 0x02, 0x03); - let line = "fe80::aa40:25ff:fe01:203%techport0/10"; - assert!(has_matching_ipv6_link_local(line, &mac)); - - // Nearly the right address, but without the local bit. - let line = "fe80::a840:25ff:fe01:203%techport0/10"; - assert!(!has_matching_ipv6_link_local(line, &mac)); - - // No scope ID - let line = "fe80::aa40:25ff:fe01:203"; - assert!(!has_matching_ipv6_link_local(line, &mac)); - - // Not a link-local at all - let line = "2001::aa40:25ff:fe01:203%techport0/10"; - assert!(!has_matching_ipv6_link_local(line, &mac)); - } -} From 7b670c83387c75bcd7f468013e65fde69a2707eb Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Wed, 20 May 2026 06:10:16 +0000 Subject: [PATCH 5/5] fixup non-illumos build after rename --- dpd/src/dhcpv6/dummy.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dpd/src/dhcpv6/dummy.rs b/dpd/src/dhcpv6/dummy.rs index f8d63410..be51d9eb 100644 --- a/dpd/src/dhcpv6/dummy.rs +++ b/dpd/src/dhcpv6/dummy.rs @@ -7,10 +7,10 @@ use common::network::MacAddr; use slog::Logger; -pub async fn ensure_dhcpv6_agent(log: Logger, _base_mac: MacAddr) { +pub async fn allow_dhcpv6_on_techports(log: Logger, _base_mac: MacAddr) { slog::debug!( log, - "Not running DHCPv6 agent. This software is not built for \ + "Not manipulating DHCPv6 at all. This software is not built for \ both illumos and the Tofino ASIC feature"; ); }