Skip to content

Commit d0ef8ab

Browse files
feat(net-scan): align gateway scans with local planning
Adds selected network scan sources, explicit target/range planning, interface-aware scan execution, the v1 result format, and gateway API/OpenAPI support while preserving the legacy event format. ARP/NDP discovery and MAC enrichment are intentionally excluded from this review slice and remain available on backup-feat-network-scan-improvement-with-arp-ndp.
1 parent 091b317 commit d0ef8ab

34 files changed

Lines changed: 4715 additions & 631 deletions

Cargo.lock

Lines changed: 6 additions & 82 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/network-scanner-net/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ socket2 = { version = "0.5", features = ["all"] }
1717
thiserror = "2"
1818
tracing = "0.1"
1919

20+
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
21+
libc = "0.2"
22+
2023
[dev-dependencies]
2124
tracing-cov-mark = { path = "../tracing-cov-mark" }
2225
tracing-subscriber = "0.3"

crates/network-scanner-net/src/socket.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ impl AsyncRawSocket {
5252
self.socket.bind(addr)
5353
}
5454

55+
/// Bind this socket to a specific network interface by OS index.
56+
///
57+
/// Per-platform mechanism:
58+
///
59+
/// | Platform | Option used | Notes |
60+
/// |----------|------------------------------------------|-----------------------------|
61+
/// | Linux | `IP_UNICAST_IF` / `IPV6_UNICAST_IF` | No `CAP_NET_RAW` required. |
62+
/// | macOS | `IP_BOUND_IF` / `IPV6_BOUND_IF` | Apple-specific socket opts. |
63+
/// | Windows | `IP_UNICAST_IF` / `IPV6_UNICAST_IF` | Via `ws2_32!setsockopt`. |
64+
///
65+
/// Other platforms return [`std::io::ErrorKind::Unsupported`]. Taking a
66+
/// [`std::num::NonZeroU32`] makes it impossible to pass the
67+
/// "unbind / use default routing" sentinel by accident — drop the call
68+
/// site instead of binding to ifindex 0.
69+
pub fn bind_to_interface(&self, family: socket2::Domain, if_index: std::num::NonZeroU32) -> std::io::Result<()> {
70+
bind_socket_to_interface(&self.socket, family, if_index)
71+
}
72+
5573
pub async fn set_ttl(&self, ttl: u32) -> std::io::Result<()> {
5674
self.socket.set_ttl(ttl)
5775
}
@@ -65,6 +83,132 @@ impl AsyncRawSocket {
6583
}
6684
}
6785

86+
/// Bind a `socket2::Socket` to a specific interface index.
87+
///
88+
/// Per-platform mechanism (`IP_UNICAST_IF` on Linux + Windows,
89+
/// `IP_BOUND_IF` on macOS — see [`AsyncRawSocket::bind_to_interface`] for
90+
/// the full table). Returns [`std::io::ErrorKind::Unsupported`] elsewhere.
91+
fn bind_socket_to_interface(
92+
socket: &Socket,
93+
family: socket2::Domain,
94+
if_index: std::num::NonZeroU32,
95+
) -> std::io::Result<()> {
96+
let if_index = if_index.get();
97+
98+
#[cfg(any(target_os = "linux", target_os = "macos"))]
99+
{
100+
use std::os::fd::AsRawFd;
101+
102+
// Resolve (level, name, value) per platform / family. Linux and
103+
// macOS share the libc::setsockopt call shape, only the constants
104+
// and IPv4 byte-order differ.
105+
let (level, name, value): (libc::c_int, libc::c_int, u32) = match (family, cfg!(target_os = "linux")) {
106+
// Linux
107+
#[cfg(target_os = "linux")]
108+
(socket2::Domain::IPV4, _) => {
109+
// IPPROTO_IP, IP_UNICAST_IF; the kernel expects net-order.
110+
(0, 50, if_index.to_be())
111+
}
112+
#[cfg(target_os = "linux")]
113+
(socket2::Domain::IPV6, _) => {
114+
// IPPROTO_IPV6, IPV6_UNICAST_IF; host byte order.
115+
(41, 76, if_index)
116+
}
117+
// macOS
118+
#[cfg(target_os = "macos")]
119+
(socket2::Domain::IPV4, _) => {
120+
// IPPROTO_IP, IP_BOUND_IF
121+
(0, 25, if_index)
122+
}
123+
#[cfg(target_os = "macos")]
124+
(socket2::Domain::IPV6, _) => {
125+
// IPPROTO_IPV6, IPV6_BOUND_IF
126+
(41, 125, if_index)
127+
}
128+
_ => {
129+
return Err(std::io::Error::new(
130+
std::io::ErrorKind::InvalidInput,
131+
"interface bind only supported for IPv4 / IPv6 sockets",
132+
));
133+
}
134+
};
135+
136+
let fd = socket.as_raw_fd();
137+
// SAFETY: `fd` is a valid descriptor borrowed from `socket`;
138+
// `&value` is a stack pointer valid for the duration of the call.
139+
let ret = unsafe {
140+
libc::setsockopt(
141+
fd,
142+
level,
143+
name,
144+
&value as *const u32 as *const libc::c_void,
145+
size_of::<u32>() as libc::socklen_t,
146+
)
147+
};
148+
if ret == 0 {
149+
Ok(())
150+
} else {
151+
Err(std::io::Error::last_os_error())
152+
}
153+
}
154+
155+
#[cfg(target_os = "windows")]
156+
{
157+
// Windows IP_UNICAST_IF (option 31) takes a u32: net-order for IPv4,
158+
// host-order for IPv6.
159+
use std::os::windows::io::AsRawSocket;
160+
const IPPROTO_IP: i32 = 0;
161+
const IPPROTO_IPV6: i32 = 41;
162+
const IP_UNICAST_IF: i32 = 31;
163+
const IPV6_UNICAST_IF: i32 = 31;
164+
// `SOCKET` is u64 in the Windows headers; on 32-bit Windows it
165+
// still fits in `usize` since SOCKET handles never exceed pointer
166+
// width.
167+
let raw: usize = usize::try_from(socket.as_raw_socket())
168+
.map_err(|_| std::io::Error::other("Windows socket handle does not fit in usize on this target"))?;
169+
let (level, name, value): (i32, i32, u32) = if family == socket2::Domain::IPV4 {
170+
(IPPROTO_IP, IP_UNICAST_IF, if_index.to_be())
171+
} else if family == socket2::Domain::IPV6 {
172+
(IPPROTO_IPV6, IPV6_UNICAST_IF, if_index)
173+
} else {
174+
return Err(std::io::Error::new(
175+
std::io::ErrorKind::InvalidInput,
176+
"interface bind only supported for IPv4 / IPv6 sockets",
177+
));
178+
};
179+
// setsockopt takes optlen as i32; size_of::<u32>() is 4 — the cast
180+
// is infallible, but try_from documents intent and avoids a lint.
181+
let optlen = i32::try_from(size_of::<u32>()).expect("size of u32 fits in i32");
182+
// SAFETY: setsockopt is invoked on a valid Windows socket handle
183+
// that outlives this call; `&value` lives on the stack across it.
184+
let ret = unsafe { windows_setsockopt(raw, level, name, &value as *const u32 as *const u8, optlen) };
185+
if ret == 0 {
186+
Ok(())
187+
} else {
188+
Err(std::io::Error::last_os_error())
189+
}
190+
}
191+
192+
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
193+
{
194+
let _ = (socket, family, if_index);
195+
Err(std::io::Error::new(
196+
std::io::ErrorKind::Unsupported,
197+
"interface bind not supported on this platform",
198+
))
199+
}
200+
}
201+
202+
#[cfg(target_os = "windows")]
203+
unsafe extern "system" {
204+
/// `setsockopt` from `ws2_32.dll`. We deliberately bind the symbol here
205+
/// instead of pulling in the full `windows-sys` crate so the only
206+
/// network-scanner-net Windows dependency stays within libstd's
207+
/// already-linked import library.
208+
#[link_name = "setsockopt"]
209+
fn windows_setsockopt(s: usize, level: i32, optname: i32, optval: *const u8, optlen: i32) -> i32;
210+
}
211+
68212
impl<'a> AsyncRawSocket {
69213
#[tracing::instrument(skip(self, buf))]
70214
pub fn recv_from(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
//! Wire-format helpers for the protocols the network scanner speaks.
2+
//!
3+
//! - [`icmp_v4`] / [`icmp_v6`] — ICMP echo and friends used by ping.
4+
//! - [`netbios`] — NetBIOS-over-UDP name service queries.
5+
//!
6+
//! All modules are pure byte-level: they neither own sockets nor perform
7+
//! I/O. The companion `network-scanner` crate composes them with raw
8+
//! sockets to send and receive packets.
9+
110
pub mod icmp_v4;
211
pub mod icmp_v6;
312
pub mod netbios;
13+
14+
#[cfg(test)]
15+
mod tests;

0 commit comments

Comments
 (0)