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::new(connection).await?;
271    let links = p.list_links().await?;
272    let mut link_int_to_name: HashMap<i32, String> = HashMap::new();
273    for network_link in links {
274        link_int_to_name.insert(network_link.0, network_link.1);
275    }
276    Ok(link_int_to_name)
277}
278
279/// Aggregated systemd-networkd state: per-interface details and total managed interface count
280#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
281pub struct NetworkdState {
282    /// State details for each networkd-managed interface
283    pub interfaces_state: Vec<InterfaceState>,
284    /// Total number of interfaces managed by networkd (those with a NETWORK_FILE entry)
285    pub managed_interfaces: u64,
286}
287
288pub const NETWORKD_STATE_FILES: &str = "/run/systemd/netif/links";
289
290/// Parse a networkd state file contents + convert int ID to name via DBUS
291pub fn parse_interface_stats(
292    interface_state_str: &str,
293    interface_id: i32,
294    interface_id_to_name: &HashMap<i32, String>,
295) -> Result<InterfaceState, MonitordNetworkdError> {
296    let mut interface_state = InterfaceState::default();
297
298    // Pull interface name out of list_links generated HashMap (once, not per line)
299    if interface_id > 0 {
300        if let Some(name) = interface_id_to_name.get(&interface_id) {
301            interface_state.name = name.clone();
302        }
303    }
304
305    for line in interface_state_str.lines() {
306        // Skip comments + lines without =
307        if !line.contains('=') {
308            continue;
309        }
310
311        let (key, value) = line
312            .split_once('=')
313            .expect("Unable to split a network state line");
314        match key {
315            "ADDRESS_STATE" => {
316                interface_state.address_state =
317                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
318            }
319            "ADMIN_STATE" => {
320                interface_state.admin_state =
321                    AdminState::from_str(value).unwrap_or(AdminState::unknown)
322            }
323            "CARRIER_STATE" => {
324                interface_state.carrier_state =
325                    CarrierState::from_str(value).unwrap_or(CarrierState::unknown)
326            }
327            "IPV4_ADDRESS_STATE" => {
328                interface_state.ipv4_address_state =
329                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
330            }
331            "IPV6_ADDRESS_STATE" => {
332                interface_state.ipv6_address_state =
333                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
334            }
335            "NETWORK_FILE" => interface_state.network_file = value.to_string(),
336            "OPER_STATE" => {
337                interface_state.oper_state =
338                    OperState::from_str(value).unwrap_or(OperState::unknown)
339            }
340            "REQUIRED_FOR_ONLINE" => {
341                interface_state.required_for_online =
342                    BoolState::from_str(value).unwrap_or(BoolState::unknown)
343            }
344            _ => continue,
345        };
346    }
347
348    Ok(interface_state)
349}
350
351/// Parse interface state files in directory supplied
352pub async fn parse_interface_state_files(
353    states_path: &PathBuf,
354    maybe_network_int_to_name: Option<HashMap<i32, String>>,
355    maybe_connection: Option<&zbus::Connection>,
356) -> Result<NetworkdState, MonitordNetworkdError> {
357    let mut managed_interface_count: u64 = 0;
358    let mut interfaces_state = vec![];
359
360    let network_int_to_name = match maybe_network_int_to_name {
361        None => {
362            if let Some(connection) = maybe_connection {
363                match get_interface_links(connection).await {
364                    Ok(hashmap) => hashmap,
365                    Err(err) => {
366                        error!(
367                            "Unable to get interface links via DBUS - is networkd running?: {:#?}",
368                            err
369                        );
370                        return Ok(NetworkdState::default());
371                    }
372                }
373            } else {
374                error!(
375                    "Unable to get interface links via DBUS and no network_int_to_name supplied"
376                );
377                return Ok(NetworkdState::default());
378            }
379        }
380        Some(valid_hashmap) => valid_hashmap,
381    };
382
383    let mut state_file_dir_entries = tokio::fs::read_dir(states_path).await?;
384    while let Some(state_file) = state_file_dir_entries.next_entry().await? {
385        if !state_file.path().is_file() {
386            continue;
387        }
388        let interface_stats_file_str = tokio::fs::read_to_string(state_file.path()).await?;
389        if !interface_stats_file_str.contains("NETWORK_FILE") {
390            continue;
391        }
392        managed_interface_count += 1;
393        let fname = state_file.file_name();
394        let interface_id: i32 = i32::from_str(fname.to_str().unwrap_or("0")).unwrap_or(0);
395        match parse_interface_stats(
396            &interface_stats_file_str,
397            interface_id,
398            &network_int_to_name,
399        ) {
400            Ok(interface_state) => interfaces_state.push(interface_state),
401            Err(err) => error!(
402                "Unable to parse interface statistics for {:?}: {}",
403                state_file.path().into_os_string(),
404                err
405            ),
406        }
407    }
408    Ok(NetworkdState {
409        interfaces_state,
410        managed_interfaces: managed_interface_count,
411    })
412}
413
414/// Async wrapper than can update networkd stats when passed a locked struct
415pub async fn update_networkd_stats(
416    states_path: PathBuf,
417    maybe_network_int_to_name: Option<HashMap<i32, String>>,
418    connection: zbus::Connection,
419    locked_machine_stats: Arc<RwLock<MachineStats>>,
420) -> anyhow::Result<()> {
421    match parse_interface_state_files(&states_path, maybe_network_int_to_name, Some(&connection))
422        .await
423    {
424        Ok(networkd_stats) => {
425            let mut machine_stats = locked_machine_stats.write().await;
426            machine_stats.networkd = networkd_stats;
427        }
428        Err(err) => error!("networkd stats failed: {:?}", err),
429    }
430    Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::fs::File;
437    use std::io::Write;
438    use tempfile::tempdir;
439
440    const MOCK_INTERFACE_STATE: &str = r###"# This is private data. Do not parse.
441ADMIN_STATE=configured
442OPER_STATE=routable
443CARRIER_STATE=carrier
444ADDRESS_STATE=routable
445IPV4_ADDRESS_STATE=degraded
446IPV6_ADDRESS_STATE=routable
447ONLINE_STATE=online
448REQUIRED_FOR_ONLINE=yes
449REQUIRED_OPER_STATE_FOR_ONLINE=degraded:routable
450REQUIRED_FAMILY_FOR_ONLINE=any
451ACTIVATION_POLICY=up
452NETWORK_FILE=/etc/systemd/network/69-eno4.network
453NETWORK_FILE_DROPINS=""
454DNS=8.8.8.8 8.8.4.4
455NTP=
456SIP=
457DOMAINS=
458ROUTE_DOMAINS=
459LLMNR=yes
460MDNS=no
461"###;
462
463    fn return_expected_interface_state() -> InterfaceState {
464        InterfaceState {
465            address_state: AddressState::routable,
466            admin_state: AdminState::configured,
467            carrier_state: CarrierState::carrier,
468            ipv4_address_state: AddressState::degraded,
469            ipv6_address_state: AddressState::routable,
470            name: "eth0".to_string(),
471            network_file: "/etc/systemd/network/69-eno4.network".to_string(),
472            oper_state: OperState::routable,
473            required_for_online: BoolState::True,
474        }
475    }
476
477    fn return_mock_int_name_hashmap() -> Option<HashMap<i32, String>> {
478        let mut h: HashMap<i32, String> = HashMap::new();
479        h.insert(2, String::from("eth0"));
480        h.insert(69, String::from("eth69"));
481        Some(h)
482    }
483
484    #[test]
485    fn test_parse_interface_stats() {
486        assert_eq!(
487            return_expected_interface_state(),
488            parse_interface_stats(
489                MOCK_INTERFACE_STATE,
490                2,
491                &return_mock_int_name_hashmap().expect("Failed to get a mock int name hashmap"),
492            )
493            .expect("Failed to parse interface stats"),
494        );
495    }
496
497    #[test]
498    fn test_parse_interface_stats_json() {
499        // 'name' stays as an empty string cause we don't pass in networkctl json or an interface id
500        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}"###;
501        let stats = parse_interface_stats(MOCK_INTERFACE_STATE, 0, &HashMap::new()).unwrap();
502        let stats_json = serde_json::to_string(&stats).unwrap();
503        assert_eq!(expected_interface_state_json.to_string(), stats_json);
504    }
505
506    #[tokio::test]
507    async fn test_parse_interface_state_files() -> Result<(), MonitordNetworkdError> {
508        let expected_files = NetworkdState {
509            interfaces_state: vec![return_expected_interface_state()],
510            managed_interfaces: 1,
511        };
512
513        let temp_dir = tempdir()?;
514        // Filename of '2' is important as it needs to correspond to the interface id / index
515        let file_path = temp_dir.path().join("2");
516        let mut state_file = File::create(file_path)?;
517        writeln!(state_file, "{}", MOCK_INTERFACE_STATE)?;
518
519        let path = PathBuf::from(temp_dir.path());
520        assert_eq!(
521            expected_files,
522            parse_interface_state_files(
523                &path,
524                return_mock_int_name_hashmap(),
525                None, // No DBUS in tests
526            )
527            .await
528            .expect("Problem with parsing interface stte files")
529        );
530        Ok(())
531    }
532
533    #[test]
534    fn test_enums_to_ints() -> Result<(), MonitordNetworkdError> {
535        assert_eq!(3, AddressState::routable as u64);
536        let carrier_state_int: u8 = u8::from(CarrierState::degraded_carrier);
537        assert_eq!(4, carrier_state_int);
538        assert_eq!(1, BoolState::True as i64);
539        let bool_state_false_int: u8 = u8::from(BoolState::False);
540        assert_eq!(0, bool_state_false_int);
541
542        Ok(())
543    }
544}