Add runtime observation and systemd restart adapter

This commit is contained in:
2026-10-20 23:01:43 +03:00
parent 5418841b92
commit 6ec593c783
18 changed files with 6001 additions and 66 deletions
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
//! Bounded, read-only Linux Generic Netlink transport for WireGuard.
//!
//! Replies are received into zeroizing buffers. The datagram sender address
//! must be kernel port 0; `nlmsg_pid` in request replies is the requester's
//! port ID, as Linux Generic Netlink constructs replies using that destination.
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::fd::OwnedFd;
use rustix::io::Errno;
use rustix::net::{
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, bind, getsockname,
netlink::SocketAddrNetlink, recvfrom, sendto, socket_with,
};
use zeroize::Zeroizing;
use super::netlink::{DecodeContext, DecodeError, DecodeLimits, GetDeviceDecoder};
use super::{InterfaceObservation, PeerSetCompleteness, RuntimeObservation, RuntimePeerSet};
const NETLINK_GENERIC: u32 = 16;
const CTRL_FAMILY_ID: u16 = 0x10;
const CTRL_CMD_GETFAMILY: u8 = 3;
const CTRL_CMD_NEWFAMILY: u8 = 1;
const CTRL_ATTR_FAMILY_ID: u16 = 1;
const CTRL_ATTR_FAMILY_NAME: u16 = 2;
const WG_CMD_GET_DEVICE: u8 = 0;
const WG_GENL_VERSION: u8 = 1;
const WG_ATTR_IFNAME: u16 = 2;
const NLMSG_ERROR: u16 = 2;
const NLMSG_DONE: u16 = 3;
const NLMSG_REQUEST: u16 = 1;
const NLMSG_DUMP: u16 = 0x300;
const NLMSG_MULTI: u16 = 2;
const MAX_DATAGRAM: usize = 1024 * 1024;
const MAX_INTERFACE_NAME: usize = 15;
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum TransportError {
#[error("configured WireGuard interface was not found")]
NoDevice,
#[error("permission denied while reading WireGuard state")]
PermissionDenied,
#[error("WireGuard netlink response was malformed or incomplete")]
Malformed,
#[error("WireGuard netlink transport failed")]
Transport,
#[error("WireGuard netlink request timed out")]
Timeout,
#[error("WireGuard netlink response exceeded configured limits")]
LimitExceeded,
}
#[derive(Debug)]
struct Datagram {
bytes: Zeroizing<Vec<u8>>,
sender: u32,
}
trait Io {
fn now(&self) -> Duration;
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError>;
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError>;
fn port_id(&self) -> u32;
}
struct LinuxIo {
socket: OwnedFd,
port_id: u32,
origin: Instant,
}
impl LinuxIo {
fn new() -> Result<Self, TransportError> {
let socket = socket_with(
AddressFamily::NETLINK,
SocketType::DGRAM,
SocketFlags::CLOEXEC.union(SocketFlags::NONBLOCK),
Some(rustix::net::netlink::GENERIC),
)
.map_err(map_errno)?;
bind(&socket, &SocketAddrNetlink::new(0, 0)).map_err(map_errno)?;
let addr = SocketAddrNetlink::try_from(getsockname(&socket).map_err(map_errno)?)
.map_err(|_| TransportError::Transport)?;
Ok(Self {
socket,
port_id: addr.pid(),
origin: Instant::now(),
})
}
}
impl Io for LinuxIo {
fn now(&self) -> Duration {
self.origin.elapsed()
}
fn port_id(&self) -> u32 {
self.port_id
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
let sent = sendto(
&self.socket,
bytes,
SendFlags::empty(),
&SocketAddrNetlink::new(destination, 0),
)
.map_err(map_errno)?;
if sent == bytes.len() {
Ok(())
} else {
Err(TransportError::Transport)
}
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
let timeout = Timespec {
tv_sec: wait.as_secs().min(i64::MAX as u64) as i64,
tv_nsec: wait.subsec_nanos() as i64,
};
let mut fds = [PollFd::new(&self.socket, PollFlags::IN)];
if poll(&mut fds, Some(&timeout)).map_err(map_errno)? == 0 {
return Err(TransportError::Timeout);
}
let mut bytes = Zeroizing::new(vec![0; MAX_DATAGRAM]);
let (_, count, address) = recvfrom(
&self.socket,
&mut bytes[..],
RecvFlags::TRUNC.union(RecvFlags::DONTWAIT),
)
.map_err(map_errno)?;
if count > MAX_DATAGRAM {
return Err(TransportError::LimitExceeded);
}
let sender = address
.and_then(|a| SocketAddrNetlink::try_from(a).ok())
.ok_or(TransportError::Malformed)?
.pid();
bytes.truncate(count);
Ok(Datagram { bytes, sender })
}
}
fn map_errno(error: Errno) -> TransportError {
if error == Errno::ACCESS || error == Errno::PERM {
TransportError::PermissionDenied
} else if error == Errno::NOENT || error == Errno::NODEV {
TransportError::NoDevice
} else if error == Errno::TIMEDOUT {
TransportError::Timeout
} else {
TransportError::Transport
}
}
fn map_kernel_error(error: i32) -> TransportError {
if error == Errno::ACCESS.raw_os_error() || error == Errno::PERM.raw_os_error() {
TransportError::PermissionDenied
} else if error == Errno::NOENT.raw_os_error() || error == Errno::NODEV.raw_os_error() {
TransportError::NoDevice
} else {
TransportError::Transport
}
}
pub(crate) fn observe_interface(
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
let mut io = LinuxIo::new()?;
observe_with_io(&mut io, interface, timeout, limits)
}
fn observe_with_io(
io: &mut impl Io,
interface: &str,
timeout: Duration,
limits: DecodeLimits,
) -> Result<RuntimeObservation, TransportError> {
if interface.is_empty()
|| interface.len() > MAX_INTERFACE_NAME
|| interface.as_bytes().contains(&0)
|| timeout.is_zero()
{
return Err(TransportError::Malformed);
}
let start = io.now();
let deadline = start.checked_add(timeout).ok_or(TransportError::Timeout)?;
let family = resolve_family(io, deadline)?;
let seq = 2;
let request = get_device_request(family, seq, io.port_id(), interface);
io.send(0, &request)?;
let context = DecodeContext {
sequence: seq,
port_id: io.port_id(),
family_id: family,
};
let mut decoder = GetDeviceDecoder::new(context, interface, limits);
loop {
let datagram = receive_before(io, deadline)?;
if datagram.sender != 0 {
return Err(TransportError::Malformed);
}
decoder.push_datagram(&datagram.bytes).map_err(map_decode)?;
// Decoder only returns a device after its explicit terminal DONE.
if decoder.is_done() {
let device = decoder.finish().map_err(map_decode)?;
return Ok(RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: device.interface_name,
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
device.peers,
),
},
});
}
}
}
fn receive_before(io: &mut impl Io, deadline: Duration) -> Result<Datagram, TransportError> {
let remaining = deadline
.checked_sub(io.now())
.ok_or(TransportError::Timeout)?;
if remaining.is_zero() {
return Err(TransportError::Timeout);
}
io.receive(remaining)
}
fn resolve_family(io: &mut impl Io, deadline: Duration) -> Result<u16, TransportError> {
let seq = 1;
let mut request = message(CTRL_FAMILY_ID, NLMSG_REQUEST, seq, io.port_id());
request.extend_from_slice(&[CTRL_CMD_GETFAMILY, 1, 0, 0]);
push_attr(&mut request, CTRL_ATTR_FAMILY_NAME, b"wireguard\0");
set_len(&mut request);
io.send(0, &request)?;
let d = receive_before(io, deadline)?;
if d.sender != 0 {
return Err(TransportError::Malformed);
}
parse_family(&d.bytes, seq, io.port_id())
}
fn parse_family(bytes: &[u8], seq: u32, port: u32) -> Result<u16, TransportError> {
if bytes.len() < 20 {
return Err(TransportError::Malformed);
}
if read_u32(bytes, 0)? as usize != bytes.len()
|| read_u32(bytes, 8)? != seq
|| read_u32(bytes, 12)? != port
{
return Err(TransportError::Malformed);
}
let message_type = read_u16(bytes, 4)?;
if message_type == NLMSG_ERROR {
let status = read_u32(bytes, 16)? as i32;
if status == 0 {
return Err(TransportError::Malformed);
}
return Err(status
.checked_neg()
.map(map_kernel_error)
.ok_or(TransportError::Malformed)?);
}
if message_type != CTRL_FAMILY_ID {
return Err(TransportError::Malformed);
}
if bytes[16] != CTRL_CMD_NEWFAMILY || read_u16(bytes, 18)? != 0 {
return Err(TransportError::Malformed);
}
let mut off = 20;
while off < bytes.len() {
if bytes.len() - off < 4 {
return Err(TransportError::Malformed);
}
let len = read_u16(bytes, off)? as usize;
let kind = read_u16(bytes, off + 2)? & 0x3fff;
if len < 4 || off + len > bytes.len() {
return Err(TransportError::Malformed);
}
if kind == CTRL_ATTR_FAMILY_ID && len == 6 {
return read_u16(bytes, off + 4);
}
let aligned_len = len.checked_add(3).ok_or(TransportError::Malformed)? & !3;
off = off
.checked_add(aligned_len)
.filter(|&end| end <= bytes.len())
.ok_or(TransportError::Malformed)?;
}
Err(TransportError::NoDevice)
}
fn get_device_request(family: u16, seq: u32, port: u32, interface: &str) -> Vec<u8> {
let mut out = message(family, NLMSG_REQUEST | NLMSG_DUMP, seq, port);
out.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
let mut interface_name = interface.as_bytes().to_vec();
interface_name.push(0);
push_attr(&mut out, WG_ATTR_IFNAME, &interface_name);
set_len(&mut out);
out
}
fn message(kind: u16, flags: u16, seq: u32, port: u32) -> Vec<u8> {
let mut out = vec![0; 16];
out[4..6].copy_from_slice(&kind.to_ne_bytes());
out[6..8].copy_from_slice(&flags.to_ne_bytes());
out[8..12].copy_from_slice(&seq.to_ne_bytes());
out[12..16].copy_from_slice(&port.to_ne_bytes());
out
}
fn push_attr(out: &mut Vec<u8>, kind: u16, value: &[u8]) {
let len = (4 + value.len()) as u16;
out.extend_from_slice(&len.to_ne_bytes());
out.extend_from_slice(&kind.to_ne_bytes());
out.extend_from_slice(value);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
fn set_len(out: &mut [u8]) {
let len = out.len() as u32;
out[..4].copy_from_slice(&len.to_ne_bytes());
}
fn read_u16(bytes: &[u8], at: usize) -> Result<u16, TransportError> {
bytes
.get(at..at + 2)
.and_then(|b| b.try_into().ok())
.map(u16::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn read_u32(bytes: &[u8], at: usize) -> Result<u32, TransportError> {
bytes
.get(at..at + 4)
.and_then(|b| b.try_into().ok())
.map(u32::from_ne_bytes)
.ok_or(TransportError::Malformed)
}
fn map_decode(error: DecodeError) -> TransportError {
match error {
DecodeError::KernelError(n) if n == Errno::NODEV.raw_os_error() => TransportError::NoDevice,
DecodeError::KernelError(n)
if n == Errno::ACCESS.raw_os_error() || n == Errno::PERM.raw_os_error() =>
{
TransportError::PermissionDenied
}
DecodeError::LimitExceeded => TransportError::LimitExceeded,
DecodeError::KernelError(_) | DecodeError::DumpFailed => TransportError::Transport,
_ => TransportError::Malformed,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctrl_family_message(sequence: u32, port: u32, family: u16) -> Vec<u8> {
let mut out = message(CTRL_FAMILY_ID, 0, sequence, port);
out.extend_from_slice(&[CTRL_CMD_NEWFAMILY, 2, 0, 0]);
push_attr(&mut out, CTRL_ATTR_FAMILY_ID, &family.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn resolves_family_only_from_matching_kernel_reply() {
assert_eq!(parse_family(&ctrl_family_message(1, 55, 42), 1, 55), Ok(42));
assert_eq!(
parse_family(&ctrl_family_message(2, 55, 42), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&ctrl_family_message(1, 56, 42), 1, 55),
Err(TransportError::Malformed)
);
}
fn netlink_error(sequence: u32, port: u32, status: i32) -> Vec<u8> {
let mut out = message(NLMSG_ERROR, 0, sequence, port);
out.extend_from_slice(&status.to_ne_bytes());
set_len(&mut out);
out
}
#[test]
fn family_errors_map_kernel_errno_after_validating_reply_identity() {
for errno in [Errno::NOENT, Errno::NODEV] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::NoDevice)
);
}
for errno in [Errno::ACCESS, Errno::PERM] {
assert_eq!(
parse_family(&netlink_error(1, 55, -errno.raw_os_error()), 1, 55),
Err(TransportError::PermissionDenied)
);
}
assert_eq!(
parse_family(&netlink_error(1, 55, -Errno::IO.raw_os_error()), 1, 55),
Err(TransportError::Transport)
);
assert_eq!(
parse_family(&netlink_error(1, 55, i32::MIN), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 55, 0), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(2, 55, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(&netlink_error(1, 56, -Errno::NOENT.raw_os_error()), 1, 55),
Err(TransportError::Malformed)
);
assert_eq!(
parse_family(
&netlink_error(1, 55, -Errno::NOENT.raw_os_error())[..19],
1,
55
),
Err(TransportError::Malformed)
);
}
#[test]
fn family_resolution_propagates_kernel_errors_from_injected_transport() {
for (errno, expected) in [
(Errno::NODEV, TransportError::NoDevice),
(Errno::ACCESS, TransportError::PermissionDenied),
(Errno::IO, TransportError::Transport),
] {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(netlink_error(1, 55, -errno.raw_os_error()), 0));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(expected)
);
assert_eq!(fake.sent.len(), 1);
}
}
#[test]
fn get_request_targets_only_named_interface_and_dump_flags() {
let request = get_device_request(42, 7, 55, "wg-test");
assert_eq!(read_u16(&request, 4), Ok(42));
assert_eq!(read_u16(&request, 6), Ok(NLMSG_REQUEST | NLMSG_DUMP));
assert_eq!(
&request[16..20],
&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]
);
assert_eq!(read_u16(&request, 20), Ok(12));
assert_eq!(&request[22..24], &2u16.to_ne_bytes());
assert_eq!(&request[24..32], b"wg-test\0");
assert_eq!(request.len(), 32);
assert_eq!(read_u32(&request, 0), Ok(request.len() as u32));
}
#[derive(Default)]
struct FakeIo {
clock: Duration,
port: u32,
sent: Vec<(u32, Vec<u8>)>,
incoming: std::collections::VecDeque<Datagram>,
}
impl Io for FakeIo {
fn now(&self) -> Duration {
self.clock
}
fn port_id(&self) -> u32 {
self.port
}
fn send(&mut self, destination: u32, bytes: &[u8]) -> Result<(), TransportError> {
self.sent.push((destination, bytes.to_vec()));
Ok(())
}
fn receive(&mut self, wait: Duration) -> Result<Datagram, TransportError> {
if let Some(datagram) = self.incoming.pop_front() {
Ok(datagram)
} else {
self.clock += wait;
Err(TransportError::Timeout)
}
}
}
fn datagram(bytes: Vec<u8>, sender: u32) -> Datagram {
Datagram {
bytes: Zeroizing::new(bytes),
sender,
}
}
#[test]
fn injected_transport_rejects_non_kernel_sender_and_bounds_resolution_wait() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 22));
assert_eq!(
resolve_family(&mut fake, Duration::from_secs(1)),
Err(TransportError::Malformed)
);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, 0);
let mut silent = FakeIo {
port: 55,
..FakeIo::default()
};
assert_eq!(
resolve_family(&mut silent, Duration::from_millis(5)),
Err(TransportError::Timeout)
);
assert_eq!(silent.clock, Duration::from_millis(5));
}
#[test]
fn injected_transport_returns_only_a_complete_public_empty_observation() {
let mut fake = FakeIo {
port: 55,
..FakeIo::default()
};
fake.incoming
.push_back(datagram(ctrl_family_message(1, 55, 42), 0));
let mut response = message(42, NLMSG_MULTI, 2, 55);
response.extend_from_slice(&[WG_CMD_GET_DEVICE, WG_GENL_VERSION, 0, 0]);
push_attr(&mut response, 2, b"wg0\0");
push_attr(&mut response, 8 | 0x8000, &[]);
set_len(&mut response);
let mut complete = response;
let done_start = complete.len();
complete.extend_from_slice(&message(NLMSG_DONE, 0, 2, 55));
complete.extend_from_slice(&0i32.to_ne_bytes());
set_len(&mut complete[done_start..]);
fake.incoming.push_back(datagram(complete, 0));
let observation = observe_with_io(
&mut fake,
"wg0",
Duration::from_secs(2),
DecodeLimits::default(),
)
.unwrap();
assert!(
matches!(observation.interface, InterfaceObservation::Present { ref name, ref peers } if name == "wg0" && peers.completeness == PeerSetCompleteness::Complete && peers.peers.is_empty())
);
assert_eq!(fake.sent.len(), 2);
assert!(fake.sent.iter().all(|(destination, _)| *destination == 0));
}
}