Skip to content

Commit 3e66478

Browse files
authored
NDP: bind RA sockets to interface, add RA validation (#780)
* falcon-lab: improve failure-time diagnostics collection When a trio test fails, capture more of the state needed to debug it, and fix gaps that left peer diagnostics empty: - ox node: kernel neighbor caches (ndp -a -n, arp -a -n) plus, per BGP router, mgd's NDP-manager state and per-interface discovered peers via the admin API. These surface the link-locals each unnumbered interface learned. - EOS node: disable paging (terminal length 0) and capture each show command separately — a single bundled Cli call stalled on the pager and produced nothing. Also capture the ceos container log and host interface state. - FRR node: trim and drop blank lines when flattening the vtysh script; indented/empty lines became '-c " show ..."' / '-c ""', which vtysh rejects, yielding empty output. Signed-off-by: Trey Aspelund <trey@oxidecomputer.com> * ndp: scope discovery sockets to their interface Each per-interface NDP socket binds to the unspecified address to catch both unicast and multicast solicitations/advertisements. That bind does not scope reception to the interface: illumos only honors the bind sockaddr's scope_id for a link-scoped address, so ::%index leaves the socket's incoming ifindex unset. Multicast reception is still constrained by per-interface group membership, but unicast ICMPv6 is delivered to every NDP socket in the process regardless of arrival link, so a unicast RA from an off-link source can poison an interface's discovered peer and break unnumbered peering. Bind each socket to its interface via socket2's bind_device_by_index_v6 (IPV6_BOUND_IF on illumos, SO_BINDTOIFINDEX on Linux), scoping both unicast and multicast reception to the intended interface. Signed-off-by: Trey Aspelund <trey@oxidecomputer.com> * ndp: validate received router advertisements per RFC 4861 §6.1.2 Apply the RFC 4861 §6.1.2 RA validity checks that aren't otherwise enforced: - Source must be link-local: the kernel does not filter raw-socket delivery by source scope, so a globally- or otherwise-scoped source would be accepted. Reject non-link-local sources in handle_ra. (This does not guard against an off-link link-local source on the wrong interface; interface scoping handles that — see the discovery-socket commit.) - ICMP length >= 16 octets: guard in Icmp6RouterAdvertisement::from_wire. Besides matching the RFC, this prevents a panic: the ispf deserializer indexes the input directly and panics on a buffer shorter than the struct rather than returning an error. - Hop limit 255: already enforced by the kernel via IPV6_MINHOPCOUNT, which was previously set with a hardcoded illumos constant in an ungated unsafe block. Move it behind a cfg-gated helper that works on Linux (libc constant) and illumos (value from <netinet/in.h>), no-op elsewhere. The checksum check is left to the kernel, which verifies the ICMPv6 checksum before raw delivery. Signed-off-by: Trey Aspelund <trey@oxidecomputer.com> --------- Signed-off-by: Trey Aspelund <trey@oxidecomputer.com>
1 parent f2e1121 commit 3e66478

8 files changed

Lines changed: 140 additions & 45 deletions

File tree

falcon-lab/src/eos.rs

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -71,39 +71,40 @@ impl EosNode {
7171
LinuxNode(self.0)
7272
}
7373

74-
/// Capture protocol-specific state via the Arista CLI.
74+
/// Capture protocol-specific state via the Arista CLI, plus container and
75+
/// host state.
7576
pub async fn collect_diagnostics(
7677
&self,
7778
d: &Runner,
7879
topo: &str,
7980
protocols: ProtocolDiagnostics,
8081
) {
8182
let name = self.name(d);
82-
// `Cli -c` takes a single newline-separated script. Linux diagnostics
83-
// below collect host/container interfaces and routes for every test,
84-
// so keep this focused on the protocol under test.
85-
let script = if protocols.bgp() {
86-
"enable
87-
show running-config
88-
show ip bgp summary
89-
show ip bgp
90-
show ipv6 bgp
91-
"
92-
} else {
93-
"enable
94-
show running-config
95-
show bfd peers
96-
"
97-
};
98-
match self.shell(d, script).await {
99-
Ok(out) => crate::diagnostics::write_artifact(
100-
d,
101-
topo,
102-
&format!("{name}-cli"),
103-
None,
104-
&out,
105-
),
106-
Err(e) => slog::warn!(d.log, "diagnostics {name}-cli: {e}"),
83+
let mut commands = vec![("running-config", "show running-config")];
84+
if protocols.bgp() {
85+
commands.extend([
86+
("ip-bgp-summary", "show ip bgp summary"),
87+
("ip-bgp", "show ip bgp"),
88+
("ipv6-bgp", "show ipv6 bgp"),
89+
]);
90+
}
91+
if protocols.bfd() {
92+
commands.push(("bfd-peers", "show bfd peers"));
93+
}
94+
for (suffix, cmd) in commands {
95+
let script = format!("enable\n{cmd}");
96+
match self.shell(d, &script).await {
97+
Ok(out) => crate::diagnostics::write_artifact(
98+
d,
99+
topo,
100+
&format!("{name}-{suffix}"),
101+
Some(cmd),
102+
&out,
103+
),
104+
Err(e) => {
105+
slog::warn!(d.log, "diagnostics {name}-{suffix}: {e}")
106+
}
107+
}
107108
}
108109
self.linux().collect_diagnostics(d, topo, &name).await;
109110
self.linux()

falcon-lab/src/frr.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ impl FrrNode {
127127
.collect::<Vec<_>>()
128128
.join(" ");
129129
let output = d
130-
.exec(self.0, &format!("vtysh {args}"))
130+
.exec(self.0, &format!("/usr/bin/vtysh {args}"))
131131
.await
132132
.context("vtysh shell failed")?;
133133
Ok(output)

falcon-lab/src/illumos.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ impl IllumosNode {
144144
("ipadm", "ipadm show-addr"),
145145
("dladm", "dladm show-link"),
146146
("netstat", "netstat -nr"),
147+
("ndp", "ndp -a -n"),
148+
("arp", "arp -a -n"),
147149
] {
148150
crate::diagnostics::capture(
149151
d,

falcon-lab/src/mgd.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,49 @@ impl MgdNode {
6262
Err(e) => slog::warn!(d.log, "diagnostics {label}: {e}"),
6363
}
6464
}
65+
66+
pub async fn collect_ndp_diagnostics(
67+
&self,
68+
d: &Runner,
69+
client: &Client,
70+
topo: &str,
71+
) {
72+
let name = d.get_node(self.0).name.clone();
73+
let routers = match client.read_routers().await {
74+
Ok(r) => r.into_inner(),
75+
Err(e) => {
76+
slog::warn!(d.log, "diagnostics {name}-ndp: read routers: {e}");
77+
return;
78+
}
79+
};
80+
for router in routers {
81+
let asn = router.asn;
82+
for (suffix, result) in [
83+
(
84+
"ndp-manager",
85+
client
86+
.get_ndp_manager_state(asn)
87+
.await
88+
.map(|r| format!("{:#?}", r.into_inner())),
89+
),
90+
(
91+
"ndp-interfaces",
92+
client
93+
.get_ndp_interfaces(asn)
94+
.await
95+
.map(|r| format!("{:#?}", r.into_inner())),
96+
),
97+
] {
98+
let label = format!("{name}-{suffix}-asn{asn}");
99+
match result {
100+
Ok(contents) => crate::diagnostics::write_artifact(
101+
d, topo, &label, None, &contents,
102+
),
103+
Err(e) => slog::warn!(d.log, "diagnostics {label}: {e}"),
104+
}
105+
}
106+
}
107+
}
65108
}
66109

67110
pub async fn wait_for_mgd(

falcon-lab/src/test.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ where
152152
let cr1 = bt.cr1;
153153
let cr2 = bt.cr2;
154154
let cr3 = bt.cr3;
155+
let mgd = bt.mgd.clone();
155156
let topo_name = bt.topo_name.clone();
156157
let protocols = bt.protocols;
157158
let result = body(bt).await;
@@ -165,6 +166,7 @@ where
165166
if diag_on_fail {
166167
collect_diagnostics(&ad, ox, cr1, cr2, cr3, &topo_name, protocols)
167168
.await;
169+
ox.collect_ndp_diagnostics(&ad, &mgd, &topo_name).await;
168170
}
169171
}
170172
result

ndp/src/manager.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::util::{
1111
};
1212
use mg_common::thread::ManagedThread;
1313
use mg_common::{lock, read_lock, write_lock};
14-
use slog::{Logger, error};
14+
use slog::{Logger, debug, error};
1515
use socket2::Socket;
1616
use std::mem::MaybeUninit;
1717
use std::net::Ipv6Addr;
@@ -340,6 +340,16 @@ impl InterfaceNdpManagerInner {
340340
/// is updated as well as the time of reception and the stored advertisement
341341
/// containing the reachable time.
342342
fn handle_ra(&self, ra: Icmp6RouterAdvertisement, src: Ipv6Addr) {
343+
// Per RFC 4861 Section 6.1.2: a valid RA's source is always link-local
344+
if !src.is_unicast_link_local() {
345+
debug!(
346+
self.log,
347+
"ignoring RA from non-link-local source {src} on {}",
348+
self.ifx.name,
349+
);
350+
return;
351+
}
352+
343353
let mut guard = lock!(self.neighbor_router);
344354
let now = Instant::now();
345355

ndp/src/packet.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,13 @@ impl Icmp6RouterAdvertisement {
3939
const TYPE: u8 = 134;
4040
const CODE: u8 = 0;
4141
const DEFAULT_HOPLIMIT: u8 = 255;
42+
const MIN_PAYLOAD_LEN: usize = 16;
4243

4344
pub fn from_wire(buf: &[u8]) -> Result<Self, Icmp6RaFromWireError> {
45+
// Per RFC 4861 Section 6.1.2: a valid RA has an ICMP payload of >= 16b
46+
if buf.len() < Self::MIN_PAYLOAD_LEN {
47+
return Err(Icmp6RaFromWireError::TooShort(buf.len()));
48+
}
4449
let s: Self = ispf::from_bytes_be(buf)?;
4550
if s.typ != Self::TYPE {
4651
return Err(Icmp6RaFromWireError::WrongType(s.typ));
@@ -86,6 +91,9 @@ pub enum Icmp6RaFromWireError {
8691
#[error("deserialization error: {0}")]
8792
Ispf(#[from] ispf::Error),
8893

94+
#[error("too short: {0} octets, expected at least 16")]
95+
TooShort(usize),
96+
8997
#[error("wrong type: expected {expected}, got {0}", expected = Icmp6RouterAdvertisement::TYPE)]
9098
WrongType(u8),
9199

ndp/src/util.rs

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
// Copyright 2026 Oxide Computer Company
66

77
use crate::packet::{Icmp6RouterAdvertisement, Icmp6RouterSolicitation};
8-
use libc::{c_int, socklen_t};
98
use slog::{Logger, error};
109
use socket2::{Domain, Protocol, Socket, Type};
1110
use std::{
12-
ffi::c_void,
1311
net::{Ipv6Addr, SocketAddrV6},
14-
os::fd::AsRawFd,
12+
num::NonZeroU32,
1513
thread::sleep,
1614
time::{Duration, Instant},
1715
};
@@ -70,8 +68,15 @@ pub enum ListeningSocketError {
7068
#[error("set read timeout error: {0}")]
7169
SetReadTimeoutError(std::io::Error),
7270

71+
#[cfg(any(target_os = "linux", target_os = "illumos"))]
7372
#[error("failed to set ipv6 min hop count: {0}")]
7473
SetIpv6MinHopCount(std::io::Error),
74+
75+
#[error("failed to bind socket to interface: {0}")]
76+
SetBoundIf(std::io::Error),
77+
78+
#[error("interface index must be non-zero")]
79+
InvalidInterfaceIndex,
7580
}
7681

7782
pub fn send_ra(
@@ -205,28 +210,52 @@ pub fn create_socket(index: u32) -> Result<Socket, ListeningSocketError> {
205210
s.join_multicast_v6(&ALL_ROUTERS_MCAST, index)
206211
.map_err(E::JoinAllRoutersMulticast)?;
207212

213+
let ifindex = NonZeroU32::new(index).ok_or(E::InvalidInterfaceIndex)?;
214+
s.bind_device_by_index_v6(Some(ifindex))
215+
.map_err(E::SetBoundIf)?;
216+
208217
s.bind(&sa).map_err(ListeningSocketError::Bind)?;
209218

210219
s.set_read_timeout(Some(READ_TIMEOUT))
211220
.map_err(E::SetReadTimeoutError)?;
212221

213-
unsafe {
214-
// from <netinet/in.h>
215-
const IPV6_MINHOPCOUNT: c_int = 0x2f;
216-
let min_hops: c_int = 255;
217-
let rc = libc::setsockopt(
222+
// Per RFC 4861 Section 6.1.2: a valid RA must have a hop limit of 255
223+
set_min_hopcount(&s)?;
224+
225+
Ok(s)
226+
}
227+
228+
#[cfg(any(target_os = "linux", target_os = "illumos"))]
229+
fn set_min_hopcount(s: &Socket) -> Result<(), ListeningSocketError> {
230+
use std::os::fd::AsRawFd;
231+
232+
// illumos does not export IPV6_MINHOPCOUNT via libc; value from
233+
// <netinet/in.h>. Linux provides it.
234+
#[cfg(target_os = "illumos")]
235+
const IPV6_MINHOPCOUNT: libc::c_int = 0x2f;
236+
#[cfg(target_os = "linux")]
237+
use libc::IPV6_MINHOPCOUNT;
238+
239+
let min_hops: libc::c_int = 255;
240+
// SAFETY: setsockopt with a correctly-sized pointer to an integer option.
241+
let rc = unsafe {
242+
libc::setsockopt(
218243
s.as_raw_fd(),
219244
libc::IPPROTO_IPV6,
220245
IPV6_MINHOPCOUNT,
221-
&min_hops as *const _ as *const c_void,
222-
std::mem::size_of::<libc::c_int>() as socklen_t,
223-
);
224-
if rc < 0 {
225-
return Err(ListeningSocketError::SetIpv6MinHopCount(
226-
std::io::Error::last_os_error(),
227-
));
228-
}
246+
&min_hops as *const _ as *const libc::c_void,
247+
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
248+
)
249+
};
250+
if rc < 0 {
251+
return Err(ListeningSocketError::SetIpv6MinHopCount(
252+
std::io::Error::last_os_error(),
253+
));
229254
}
255+
Ok(())
256+
}
230257

231-
Ok(s)
258+
#[cfg(not(any(target_os = "linux", target_os = "illumos")))]
259+
fn set_min_hopcount(_s: &Socket) -> Result<(), ListeningSocketError> {
260+
Ok(())
232261
}

0 commit comments

Comments
 (0)