Skip to content
Open
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
4 changes: 4 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions crates/network-scanner-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ socket2 = { version = "0.5", features = ["all"] }
thiserror = "2"
tracing = "0.1"

[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
libc = "0.2"

[dev-dependencies]
tracing-cov-mark = { path = "../tracing-cov-mark" }
tracing-subscriber = "0.3"
Expand Down
149 changes: 149 additions & 0 deletions crates/network-scanner-net/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ impl AsyncRawSocket {
self.socket.bind(addr)
}

/// Bind this socket to a specific network interface by OS index.
///
/// Per-platform mechanism:
///
/// | Platform | Option used | Notes |
/// |----------|------------------------------------------|-----------------------------|
/// | Linux | `IP_UNICAST_IF` / `IPV6_UNICAST_IF` | No `CAP_NET_RAW` required. |
/// | macOS | `IP_BOUND_IF` / `IPV6_BOUND_IF` | Apple-specific socket opts. |
/// | Windows | `IP_UNICAST_IF` / `IPV6_UNICAST_IF` | Via `ws2_32!setsockopt`. |
///
/// Other platforms return [`std::io::ErrorKind::Unsupported`]. Taking a
/// [`std::num::NonZeroU32`] makes it impossible to pass the
/// "unbind / use default routing" sentinel by accident — drop the call
/// site instead of binding to ifindex 0.
pub fn bind_to_interface(&self, family: socket2::Domain, if_index: std::num::NonZeroU32) -> std::io::Result<()> {
bind_socket_to_interface(&self.socket, family, if_index)
}

pub async fn set_ttl(&self, ttl: u32) -> std::io::Result<()> {
self.socket.set_ttl(ttl)
}
Expand All @@ -65,6 +83,137 @@ impl AsyncRawSocket {
}
}

/// Bind a `socket2::Socket` to a specific interface index.
///
/// Per-platform mechanism (`IP_UNICAST_IF` on Linux + Windows,
/// `IP_BOUND_IF` on macOS — see [`AsyncRawSocket::bind_to_interface`] for
/// the full table). The supported target list is exhaustive: only Linux,
/// macOS, and Windows are valid build targets for this crate, so other
/// platforms intentionally fail to compile rather than silently degrade.
fn bind_socket_to_interface(
socket: &Socket,
family: socket2::Domain,
if_index: std::num::NonZeroU32,
) -> std::io::Result<()> {
let if_index = if_index.get();

#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use std::os::fd::AsRawFd;

// Resolve (level, name, value) per platform / family. Linux and
// macOS share the libc::setsockopt call shape, only the constants
// and IPv4 byte-order differ.
let (level, name, value): (libc::c_int, libc::c_int, u32) = match (family, cfg!(target_os = "linux")) {
// Linux
#[cfg(target_os = "linux")]
(socket2::Domain::IPV4, _) => {
// IPPROTO_IP, IP_UNICAST_IF; the kernel expects net-order.
(0, 50, if_index.to_be())
}
#[cfg(target_os = "linux")]
(socket2::Domain::IPV6, _) => {
// IPPROTO_IPV6, IPV6_UNICAST_IF; host byte order.
(41, 76, if_index)
}
// macOS
#[cfg(target_os = "macos")]
(socket2::Domain::IPV4, _) => {
// IPPROTO_IP, IP_BOUND_IF
(0, 25, if_index)
}
#[cfg(target_os = "macos")]
(socket2::Domain::IPV6, _) => {
// IPPROTO_IPV6, IPV6_BOUND_IF
(41, 125, if_index)
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"interface bind only supported for IPv4 / IPv6 sockets",
));
}
};

let fd = socket.as_raw_fd();
// `socklen_t` is u32 on Linux / macOS but the cast from usize still
// trips `clippy::cast_possible_truncation` on 64-bit pointer widths;
// route through `try_from` to document intent.
let optlen = libc::socklen_t::try_from(size_of::<u32>()).expect("size of u32 fits in socklen_t");
// SAFETY: `fd` is a valid descriptor borrowed from `socket`;
// `&value` is a stack pointer valid for the duration of the call.
let ret = unsafe { libc::setsockopt(fd, level, name, &value as *const u32 as *const libc::c_void, optlen) };
if ret == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}

#[cfg(target_os = "windows")]
{
// Windows IP_UNICAST_IF (option 31) takes a u32: net-order for IPv4,
// host-order for IPv6.
use std::os::windows::io::AsRawSocket;
const IPPROTO_IP: i32 = 0;
const IPPROTO_IPV6: i32 = 41;
const IP_UNICAST_IF: i32 = 31;
const IPV6_UNICAST_IF: i32 = 31;
// `SOCKET` is u64 in the Windows headers; on 32-bit Windows it
// still fits in `usize` since SOCKET handles never exceed pointer
// width.
let raw: usize = usize::try_from(socket.as_raw_socket())
.map_err(|_| std::io::Error::other("Windows socket handle does not fit in usize on this target"))?;
let (level, name, value): (i32, i32, u32) = if family == socket2::Domain::IPV4 {
(IPPROTO_IP, IP_UNICAST_IF, if_index.to_be())
} else if family == socket2::Domain::IPV6 {
(IPPROTO_IPV6, IPV6_UNICAST_IF, if_index)
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"interface bind only supported for IPv4 / IPv6 sockets",
));
};
// setsockopt takes optlen as i32; size_of::<u32>() is 4 — the cast
// is infallible, but try_from documents intent and avoids a lint.
let optlen = i32::try_from(size_of::<u32>()).expect("size of u32 fits in i32");
// SAFETY: setsockopt is invoked on a valid Windows socket handle
// that outlives this call; `&value` lives on the stack across it.
let ret = unsafe { windows_setsockopt(raw, level, name, &value as *const u32 as *const u8, optlen) };
if ret == 0 {
Ok(())
} else {
// Winsock surfaces failures through `WSAGetLastError`, not the
// generic `GetLastError` that `std::io::Error::last_os_error`
// reads. Going through `last_os_error` here would occasionally
// yield stale or unrelated codes.
// SAFETY: WSAGetLastError takes no arguments and is always safe to
// call from any thread; it reads thread-local state.
let raw_errno = unsafe { WSAGetLastError() };
Err(std::io::Error::from_raw_os_error(raw_errno))
}
}
}

#[cfg(target_os = "windows")]
unsafe extern "system" {
/// `setsockopt` from `ws2_32.dll`. We deliberately bind the symbol here
/// instead of pulling in the full `windows-sys` crate so the only
/// network-scanner-net Windows dependency stays within libstd's
/// already-linked import library.
#[link_name = "setsockopt"]
fn windows_setsockopt(s: usize, level: i32, optname: i32, optval: *const u8, optlen: i32) -> i32;
}

#[cfg(target_os = "windows")]
#[link(name = "ws2_32")]
unsafe extern "system" {
/// Winsock's thread-local error accessor. Linked separately from
/// `windows_setsockopt` so the symbol can be reused by future Winsock
/// callers without splitting the extern block.
fn WSAGetLastError() -> i32;
}

impl<'a> AsyncRawSocket {
#[tracing::instrument(skip(self, buf))]
pub fn recv_from(
Expand Down
12 changes: 12 additions & 0 deletions crates/network-scanner-proto/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
//! Wire-format helpers for the protocols the network scanner speaks.
//!
//! - [`icmp_v4`] / [`icmp_v6`] — ICMP echo and friends used by ping.
//! - [`netbios`] — NetBIOS-over-UDP name service queries.
//!
//! All modules are pure byte-level: they neither own sockets nor perform
//! I/O. The companion `network-scanner` crate composes them with raw
//! sockets to send and receive packets.

pub mod icmp_v4;
pub mod icmp_v6;
pub mod netbios;

#[cfg(test)]
mod tests;
123 changes: 0 additions & 123 deletions crates/network-scanner-proto/src/netbios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,126 +74,3 @@ impl<'a> NetBiosPacket<'a> {
)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn create_nbt_packet_from_data_slice() {
let mut data = [0u8; 1024];
let packet = [
0xA2, 0x48, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x77, 0x04, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x20, 0x44, 0x00, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0xC4, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x1E, 0xC4, 0x00, 0x2C, 0x41, 0x38, 0xBA, 0xC3, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for (i, elem) in packet.iter().enumerate() {
data[i] = *elem;
}
let _actual = NetBiosPacket::from(Ipv4Addr::from([127, 0, 0, 1]), &data);
assert_eq!(true, true)
}

#[test]
fn parse_name_from_data_correctly() {
let mut data = [0u8; 1024];
let packet = [
0xA2, 0x48, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x77, 0x04, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x20, 0x44, 0x00, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0xC4, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x1E, 0xC4, 0x00, 0x2C, 0x41, 0x38, 0xBA, 0xC3, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for (i, elem) in packet.iter().enumerate() {
data[i] = *elem;
}
let expected = "JACKIEG-WS";
let actual = NetBiosPacket::from(Ipv4Addr::from([127, 0, 0, 1]), &data);

assert_eq!(expected, actual.name());
}

#[test]
fn parse_group_from_data_correctly() {
let mut data = [0u8; 1024];
let packet = [
0xA2, 0x48, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x77, 0x04, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x20, 0x44, 0x00, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0xC4, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x1E, 0xC4, 0x00, 0x2C, 0x41, 0x38, 0xBA, 0xC3, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for (i, elem) in packet.iter().enumerate() {
data[i] = *elem;
}
let expected = String::from("JACKIEG-WS");
let actual = NetBiosPacket::from(Ipv4Addr::from([127, 0, 0, 1]), &data);

assert_eq!(Some(expected), actual.group());
}

#[test]
fn parse_name_and_group_from_data_correctly_2() {
let mut data = [0u8; 1024];
let packet = [
0xA2, 0x48, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x77, 0x04, 0x41, 0x4C, 0x45, 0x58, 0x4B, 0x2D, 0x50, 0x43, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0xC4, 0x00, 0x41, 0x4C, 0x45, 0x58, 0x4B, 0x2D, 0x50, 0x43, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x20, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x1E, 0xC4, 0x00, 0xD0, 0xBF, 0x9C, 0xE4, 0x24, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for (i, elem) in packet.iter().enumerate() {
data[i] = *elem;
}
let expected = "ALEXK-PC";
let actual = NetBiosPacket::from(Ipv4Addr::from([127, 0, 0, 1]), &data);

assert_eq!(expected, actual.name());
}

#[test]
fn parse_mac_from_data_correctly() {
let mut data = [0u8; 1024];
let packet = [
0xA2, 0x48, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x77, 0x04, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x20, 0x44, 0x00, 0x4A, 0x41, 0x43, 0x4B, 0x49, 0x45, 0x47, 0x2D, 0x57, 0x53, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0x44, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x00, 0xC4, 0x00, 0x53, 0x50, 0x49, 0x43, 0x45, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
0x1E, 0xC4, 0x00, 0x2C, 0x41, 0x38, 0xBA, 0xC3, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for (i, elem) in packet.iter().enumerate() {
data[i] = *elem;
}
let expected = "2C:41:38:BA:C3:64";
let actual = NetBiosPacket::from(Ipv4Addr::from([127, 0, 0, 1]), &data);

assert_eq!(expected, actual.mac_address());
}
}
8 changes: 8 additions & 0 deletions crates/network-scanner-proto/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! All in-tree unit tests for the `network-scanner-proto` crate.
//!
//! Mirrors the source-module layout: one submodule per protocol module so
//! tests don't get tangled with implementation files.

#![allow(clippy::unwrap_used)] // tests deliberately panic on fixture-parse failure

mod netbios;
Loading
Loading