monitord/
networkd.rs

1//! # networkd module
2//!
3//! All structs, enums and methods specific to systemd-networkd.
4//! Enumerations were copied from <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::str::FromStr;
9use std::sync::Arc;
10
11use int_enum::IntEnum;
12use serde_repr::*;
13use strum_macros::EnumIter;
14use strum_macros::EnumString;
15use thiserror::Error;
16use tokio::sync::RwLock;
17use tracing::error;
18
19use crate::MachineStats;
20
21#[derive(Error, Debug)]
22pub enum MonitordNetworkdError {
23    #[error("Networkd D-Bus error: {0}")]
24    ZbusError(#[from] zbus::Error),
25    #[error("IO error: {0}")]
26    IoError(#[from] std::io::Error),
27}
28
29/// Address configuration state of a networkd-managed interface.
30/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
31#[allow(non_camel_case_types)]
32#[derive(
33    Serialize_repr,
34    Deserialize_repr,
35    Clone,
36    Copy,
37    Debug,
38    Default,
39    Eq,
40    PartialEq,
41    EnumIter,
42    EnumString,
43    IntEnum,
44    strum_macros::Display,
45)]
46#[repr(u8)]
47pub enum AddressState {
48    /// Address state could not be determined
49    #[default]
50    unknown = 0,
51    /// No addresses are configured on this interface
52    off = 1,
53    /// Addresses are configured but none provide full connectivity (e.g. link-local only)
54    degraded = 2,
55    /// At least one globally routable address is configured
56    routable = 3,
57}
58
59/// Administrative state of a networkd-managed interface (networkd's own management lifecycle).
60/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
61#[allow(non_camel_case_types)]
62#[derive(
63    Serialize_repr,
64    Deserialize_repr,
65    Clone,
66    Copy,
67    Debug,
68    Default,
69    Eq,
70    PartialEq,
71    EnumIter,
72    EnumString,
73    IntEnum,
74    strum_macros::Display,
75)]
76#[repr(u8)]
77pub enum AdminState {
78    /// Administrative state could not be determined
79    #[default]
80    unknown = 0,
81    /// Interface is pending configuration by networkd
82    pending = 1,
83    /// networkd failed to configure this interface
84    failed = 2,
85    /// Interface is currently being configured by networkd
86    configuring = 3,
87    /// Interface has been successfully configured by networkd
88    configured = 4,
89    /// Interface is not managed by networkd
90    unmanaged = 5,
91    /// Interface is lingering (was managed but its .network file was removed)
92    linger = 6,
93}
94
95/// Enumeration of a true (yes) / false (no) options - e.g. required for online
96#[allow(non_camel_case_types)]
97#[derive(
98    Serialize_repr,
99    Deserialize_repr,
100    Clone,
101    Copy,
102    Debug,
103    Default,
104    Eq,
105    PartialEq,
106    EnumIter,
107    EnumString,
108    IntEnum,
109    strum_macros::Display,
110)]
111#[repr(u8)]
112pub enum BoolState {
113    #[default]
114    unknown = u8::MAX,
115    #[strum(
116        serialize = "false",
117        serialize = "False",
118        serialize = "no",
119        serialize = "No"
120    )]
121    False = 0,
122    #[strum(
123        serialize = "true",
124        serialize = "True",
125        serialize = "yes",
126        serialize = "Yes"
127    )]
128    True = 1,
129}
130
131/// Physical carrier (link layer) state of a networkd-managed interface.
132/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
133#[allow(non_camel_case_types)]
134#[derive(
135    Serialize_repr,
136    Deserialize_repr,
137    Clone,
138    Copy,
139    Debug,
140    Default,
141    Eq,
142    PartialEq,
143    EnumIter,
144    EnumString,
145    IntEnum,
146    strum_macros::Display,
147)]
148#[repr(u8)]
149pub enum CarrierState {
150    /// Carrier state could not be determined
151    #[default]
152    unknown = 0,
153    /// Interface is administratively down (IFF_UP not set)
154    off = 1,
155    /// Interface is up but no carrier signal detected (cable unplugged or no link partner)
156    #[strum(serialize = "no-carrier", serialize = "no_carrier")]
157    no_carrier = 2,
158    /// Carrier detected but interface is in a dormant/standby state
159    dormant = 3,
160    /// Carrier detected but in a degraded condition
161    #[strum(serialize = "degraded-carrier", serialize = "degraded_carrier")]
162    degraded_carrier = 4,
163    /// Full carrier signal present and link is operational
164    carrier = 5,
165    /// Interface is enslaved to a bond/bridge master
166    enslaved = 6,
167}
168
169/// Overall online state of the system as determined by systemd-networkd-wait-online logic.
170/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
171#[allow(non_camel_case_types)]
172#[derive(
173    Serialize_repr,
174    Deserialize_repr,
175    Clone,
176    Copy,
177    Debug,
178    Default,
179    Eq,
180    PartialEq,
181    EnumIter,
182    EnumString,
183    IntEnum,
184    strum_macros::Display,
185)]
186#[repr(u8)]
187pub enum OnlineState {
188    /// Online state could not be determined
189    #[default]
190    unknown = 0,
191    /// No required interfaces are online
192    offline = 1,
193    /// Some required interfaces are online but not all
194    partial = 2,
195    /// All required interfaces are online
196    online = 3,
197}
198
199/// Operational state of a networkd-managed interface combining carrier and address information.
200/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
201#[allow(non_camel_case_types)]
202#[derive(
203    Serialize_repr,
204    Deserialize_repr,
205    Clone,
206    Copy,
207    Debug,
208    Default,
209    Eq,
210    PartialEq,
211    EnumIter,
212    EnumString,
213    IntEnum,
214    strum_macros::Display,
215)]
216#[repr(u8)]
217pub enum OperState {
218    /// Operational state could not be determined
219    #[default]
220    unknown = 0,
221    /// Interface is missing from the system
222    missing = 1,
223    /// Interface is administratively down
224    off = 2,
225    /// Interface is up but has no carrier signal
226    #[strum(serialize = "no-carrier", serialize = "no_carrier")]
227    no_carrier = 3,
228    /// Interface has carrier but is in a dormant/standby state
229    dormant = 4,
230    /// Interface carrier is in a degraded condition
231    #[strum(serialize = "degraded-carrier", serialize = "degraded_carrier")]
232    degraded_carrier = 5,
233    /// Interface has carrier but no addresses configured
234    carrier = 6,
235    /// Interface is operational but only has link-local or non-routable addresses
236    degraded = 7,
237    /// Interface is enslaved to a bond/bridge master
238    enslaved = 8,
239    /// Interface is fully operational with at least one routable address
240    routable = 9,
241}
242
243/// Per-interface state collected from systemd-networkd state files in /run/systemd/netif/links/
244#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
245pub struct InterfaceState {
246    /// Combined address state across all address families (IPv4 + IPv6)
247    pub address_state: AddressState,
248    /// networkd administrative state (whether networkd has finished configuring this interface)
249    pub admin_state: AdminState,
250    /// Physical carrier (link layer) state of the interface
251    pub carrier_state: CarrierState,
252    /// IPv4-specific address state (off, degraded, or routable)
253    pub ipv4_address_state: AddressState,
254    /// IPv6-specific address state (off, degraded, or routable)
255    pub ipv6_address_state: AddressState,
256    /// Interface name as reported by the kernel (e.g. "eth0", "enp3s0")
257    pub name: String,
258    /// Path to the .network configuration file applied to this interface
259    pub network_file: String,
260    /// Operational state combining carrier detection and address configuration
261    pub oper_state: OperState,
262    /// Whether this interface is required for the system to be considered online
263    pub required_for_online: BoolState,
264}
265
266/// Get interface id + name from dbus list_links API
267async fn get_interface_links(
268    connection: &zbus::Connection,
269) -> Result<HashMap<i32, String>, MonitordNetworkdError> {
270    let p = crate::dbus::zbus_networkd::ManagerProxy::builder(connection)
271        .cache_properties(zbus::proxy::CacheProperties::No)
272        .build()
273        .await?;
274    let links = p.list_links().await?;
275    let mut link_int_to_name: HashMap<i32, String> = HashMap::new();
276    for network_link in links {
277        link_int_to_name.insert(network_link.0, network_link.1);
278    }
279    Ok(link_int_to_name)
280}
281
282/// Aggregated systemd-networkd state: per-interface details and total managed interface count
283#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
284pub struct NetworkdState {
285    /// State details for each networkd-managed interface
286    pub interfaces_state: Vec<InterfaceState>,
287    /// Total number of interfaces managed by networkd (those with a NETWORK_FILE entry)
288    pub managed_interfaces: u64,
289}
290
291pub const NETWORKD_STATE_FILES: &str = "/run/systemd/netif/links";
292
293/// Parse a networkd state file contents + convert int ID to name via DBUS
294pub fn parse_interface_stats(
295    interface_state_str: &str,
296    interface_id: i32,
297    interface_id_to_name: &HashMap<i32, String>,
298) -> Result<InterfaceState, MonitordNetworkdError> {
299    let mut interface_state = InterfaceState::default();
300
301    // Pull interface name out of list_links generated HashMap (once, not per line)
302    if interface_id > 0 {
303        if let Some(name) = interface_id_to_name.get(&interface_id) {
304            interface_state.name = name.clone();
305        }
306    }
307
308    for line in interface_state_str.lines() {
309        // Skip comments + lines without =
310        if !line.contains('=') {
311            continue;
312        }
313
314        let (key, value) = line
315            .split_once('=')
316            .expect("Unable to split a network state line");
317        match key {
318            "ADDRESS_STATE" => {
319                interface_state.address_state =
320                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
321            }
322            "ADMIN_STATE" => {
323                interface_state.admin_state =
324                    AdminState::from_str(value).unwrap_or(AdminState::unknown)
325            }
326            "CARRIER_STATE" => {
327                interface_state.carrier_state =
328                    CarrierState::from_str(value).unwrap_or(CarrierState::unknown)
329            }
330            "IPV4_ADDRESS_STATE" => {
331                interface_state.ipv4_address_state =
332                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
333            }
334            "IPV6_ADDRESS_STATE" => {
335                interface_state.ipv6_address_state =
336                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
337            }
338            "NETWORK_FILE" => interface_state.network_file = value.to_string(),
339            "OPER_STATE" => {
340                interface_state.oper_state =
341                    OperState::from_str(value).unwrap_or(OperState::unknown)
342            }
343            "REQUIRED_FOR_ONLINE" => {
344                interface_state.required_for_online =
345                    BoolState::from_str(value).unwrap_or(BoolState::unknown)
346            }
347            _ => continue,
348        };
349    }
350
351    Ok(interface_state)
352}
353
354/// Parse interface state files in directory supplied
355pub async fn parse_interface_state_files(
356    states_path: &PathBuf,
357    maybe_network_int_to_name: Option<HashMap<i32, String>>,
358    maybe_connection: Option<&zbus::Connection>,
359) -> Result<NetworkdState, MonitordNetworkdError> {
360    let mut managed_interface_count: u64 = 0;
361    let mut interfaces_state = vec![];
362
363    let network_int_to_name = match maybe_network_int_to_name {
364        None => {
365            if let Some(connection) = maybe_connection {
366                match get_interface_links(connection).await {
367                    Ok(hashmap) => hashmap,
368                    Err(err) => {
369                        error!(
370                            "Unable to get interface links via DBUS - is networkd running?: {:#?}",
371                            err
372                        );
373                        return Ok(NetworkdState::default());
374                    }
375                }
376            } else {
377                error!(
378                    "Unable to get interface links via DBUS and no network_int_to_name supplied"
379                );
380                return Ok(NetworkdState::default());
381            }
382        }
383        Some(valid_hashmap) => valid_hashmap,
384    };
385
386    let mut state_file_dir_entries = tokio::fs::read_dir(states_path).await?;
387    while let Some(state_file) = state_file_dir_entries.next_entry().await? {
388        if !state_file.path().is_file() {
389            continue;
390        }
391        let interface_stats_file_str = tokio::fs::read_to_string(state_file.path()).await?;
392        if !interface_stats_file_str.contains("NETWORK_FILE") {
393            continue;
394        }
395        managed_interface_count += 1;
396        let fname = state_file.file_name();
397        let interface_id: i32 = i32::from_str(fname.to_str().unwrap_or("0")).unwrap_or(0);
398        match parse_interface_stats(
399            &interface_stats_file_str,
400            interface_id,
401            &network_int_to_name,
402        ) {
403            Ok(interface_state) => interfaces_state.push(interface_state),
404            Err(err) => error!(
405                "Unable to parse interface statistics for {:?}: {}",
406                state_file.path().into_os_string(),
407                err
408            ),
409        }
410    }
411    Ok(NetworkdState {
412        interfaces_state,
413        managed_interfaces: managed_interface_count,
414    })
415}
416
417/// Async wrapper than can update networkd stats when passed a locked struct
418pub async fn update_networkd_stats(
419    states_path: PathBuf,
420    maybe_network_int_to_name: Option<HashMap<i32, String>>,
421    connection: zbus::Connection,
422    locked_machine_stats: Arc<RwLock<MachineStats>>,
423) -> anyhow::Result<()> {
424    match parse_interface_state_files(&states_path, maybe_network_int_to_name, Some(&connection))
425        .await
426    {
427        Ok(networkd_stats) => {
428            let mut machine_stats = locked_machine_stats.write().await;
429            machine_stats.networkd = networkd_stats;
430        }
431        Err(err) => error!("networkd stats failed: {:?}", err),
432    }
433    Ok(())
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use std::fs::File;
440    use std::io::Write;
441    use tempfile::tempdir;
442
443    const MOCK_INTERFACE_STATE: &str = r###"# This is private data. Do not parse.
444ADMIN_STATE=configured
445OPER_STATE=routable
446CARRIER_STATE=carrier
447ADDRESS_STATE=routable
448IPV4_ADDRESS_STATE=degraded
449IPV6_ADDRESS_STATE=routable
450ONLINE_STATE=online
451REQUIRED_FOR_ONLINE=yes
452REQUIRED_OPER_STATE_FOR_ONLINE=degraded:routable
453REQUIRED_FAMILY_FOR_ONLINE=any
454ACTIVATION_POLICY=up
455NETWORK_FILE=/etc/systemd/network/69-eno4.network
456NETWORK_FILE_DROPINS=""
457DNS=8.8.8.8 8.8.4.4
458NTP=
459SIP=
460DOMAINS=
461ROUTE_DOMAINS=
462LLMNR=yes
463MDNS=no
464"###;
465
466    fn return_expected_interface_state() -> InterfaceState {
467        InterfaceState {
468            address_state: AddressState::routable,
469            admin_state: AdminState::configured,
470            carrier_state: CarrierState::carrier,
471            ipv4_address_state: AddressState::degraded,
472            ipv6_address_state: AddressState::routable,
473            name: "eth0".to_string(),
474            network_file: "/etc/systemd/network/69-eno4.network".to_string(),
475            oper_state: OperState::routable,
476            required_for_online: BoolState::True,
477        }
478    }
479
480    fn return_mock_int_name_hashmap() -> Option<HashMap<i32, String>> {
481        let mut h: HashMap<i32, String> = HashMap::new();
482        h.insert(2, String::from("eth0"));
483        h.insert(69, String::from("eth69"));
484        Some(h)
485    }
486
487    #[test]
488    fn test_parse_interface_stats() {
489        assert_eq!(
490            return_expected_interface_state(),
491            parse_interface_stats(
492                MOCK_INTERFACE_STATE,
493                2,
494                &return_mock_int_name_hashmap().expect("Failed to get a mock int name hashmap"),
495            )
496            .expect("Failed to parse interface stats"),
497        );
498    }
499
500    #[test]
501    fn test_parse_interface_stats_json() {
502        // 'name' stays as an empty string cause we don't pass in networkctl json or an interface id
503        let expected_interface_state_json = r###"{"address_state":3,"admin_state":4,"carrier_state":5,"ipv4_address_state":2,"ipv6_address_state":3,"name":"","network_file":"/etc/systemd/network/69-eno4.network","oper_state":9,"required_for_online":1}"###;
504        let stats = parse_interface_stats(MOCK_INTERFACE_STATE, 0, &HashMap::new()).unwrap();
505        let stats_json = serde_json::to_string(&stats).unwrap();
506        assert_eq!(expected_interface_state_json.to_string(), stats_json);
507    }
508
509    #[tokio::test]
510    async fn test_parse_interface_state_files() -> Result<(), MonitordNetworkdError> {
511        let expected_files = NetworkdState {
512            interfaces_state: vec![return_expected_interface_state()],
513            managed_interfaces: 1,
514        };
515
516        let temp_dir = tempdir()?;
517        // Filename of '2' is important as it needs to correspond to the interface id / index
518        let file_path = temp_dir.path().join("2");
519        let mut state_file = File::create(file_path)?;
520        writeln!(state_file, "{}", MOCK_INTERFACE_STATE)?;
521
522        let path = PathBuf::from(temp_dir.path());
523        assert_eq!(
524            expected_files,
525            parse_interface_state_files(
526                &path,
527                return_mock_int_name_hashmap(),
528                None, // No DBUS in tests
529            )
530            .await
531            .expect("Problem with parsing interface stte files")
532        );
533        Ok(())
534    }
535
536    #[test]
537    fn test_enums_to_ints() -> Result<(), MonitordNetworkdError> {
538        assert_eq!(3, AddressState::routable as u64);
539        let carrier_state_int: u8 = u8::from(CarrierState::degraded_carrier);
540        assert_eq!(4, carrier_state_int);
541        assert_eq!(1, BoolState::True as i64);
542        let bool_state_false_int: u8 = u8::from(BoolState::False);
543        assert_eq!(0, bool_state_false_int);
544
545        Ok(())
546    }
547}