Skip to main content

monitord/
dbus_stats.rs

1//! # dbus_stats module
2//!
3//! Handle getting statistics of our Dbus daemon/broker
4
5use std::collections::HashMap;
6use std::fs;
7use std::io;
8use std::path::Path;
9use std::sync::{Arc, Mutex, OnceLock};
10
11use thiserror::Error;
12use tokio::sync::RwLock;
13use tokio::sync::Semaphore;
14use tracing::Instrument;
15use tracing::{debug, error};
16use uzers::get_user_by_uid;
17use zbus::fdo::{DBusProxy, StatsProxy};
18use zbus::names::BusName;
19use zvariant::{Dict, OwnedValue, Value};
20
21use crate::MachineStats;
22
23#[derive(Error, Debug)]
24pub enum MonitordDbusStatsError {
25    #[error("D-Bus error: {0}")]
26    ZbusError(#[from] zbus::Error),
27    #[error("D-Bus fdo error: {0}")]
28    FdoError(#[from] zbus::fdo::Error),
29    #[error("Task join error: {0}")]
30    JoinError(#[from] tokio::task::JoinError),
31}
32
33// Unfortunately, various DBus daemons (ex: dbus-broker and dbus-daemon)
34// represent stats differently. Moreover, the stats vary across versions of the same daemon.
35// Hence, the code uses flexible approach providing max available information.
36
37/// Per-peer resource accounting from dbus-broker's PeerAccounting stats.
38/// Each peer represents a single D-Bus connection identified by a unique bus name.
39#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
40pub struct DBusBrokerPeerAccounting {
41    /// Unique D-Bus connection name (e.g. ":1.42")
42    pub id: String,
43    /// Well-known bus name owned by this peer, if any (e.g. "org.freedesktop.NetworkManager")
44    pub well_known_name: Option<String>,
45
46    // credentials
47    /// Unix UID of the process owning this D-Bus connection
48    pub unix_user_id: Option<u32>,
49    /// PID of the process owning this D-Bus connection
50    pub process_id: Option<u32>,
51    /// Unix supplementary group IDs of the process owning this connection
52    pub unix_group_ids: Option<Vec<u32>>,
53    // ignoring LinuxSecurityLabel
54    // pub linux_security_label: Option<String>,
55
56    // stats
57    /// Number of bus name objects held by this peer
58    pub name_objects: Option<u32>,
59    /// Bytes consumed by match rules registered by this peer
60    pub match_bytes: Option<u32>,
61    /// Number of match rules registered by this peer for signal filtering
62    pub matches: Option<u32>,
63    /// Number of pending reply objects (outstanding method calls awaiting replies)
64    pub reply_objects: Option<u32>,
65    /// Total bytes received by this peer from the bus
66    pub incoming_bytes: Option<u32>,
67    /// Total file descriptors received by this peer via D-Bus fd-passing
68    pub incoming_fds: Option<u32>,
69    /// Total bytes sent by this peer to the bus
70    pub outgoing_bytes: Option<u32>,
71    /// Total file descriptors sent by this peer via D-Bus fd-passing
72    pub outgoing_fds: Option<u32>,
73    /// Bytes used for D-Bus activation requests by this peer
74    pub activation_request_bytes: Option<u32>,
75    /// File descriptors used for D-Bus activation requests by this peer
76    pub activation_request_fds: Option<u32>,
77}
78
79impl DBusBrokerPeerAccounting {
80    /// Returns true if the peer has a well-known name
81    pub fn has_well_known_name(&self) -> bool {
82        self.well_known_name.is_some()
83    }
84
85    /// Returns the well-known name if present, otherwise falls back to the unique D-Bus connection ID
86    pub fn get_name(&self) -> &str {
87        self.well_known_name.as_deref().unwrap_or(&self.id)
88    }
89
90    pub fn get_cgroup_name(&self) -> Result<String, io::Error> {
91        let pid = self
92            .process_id
93            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "missing process_id"))?;
94
95        let path = format!("/proc/{}/cgroup", pid);
96        let content = fs::read_to_string(&path)?;
97
98        // ex: 0::/system.slice/metalos.classic.metald.service
99        let cgroup = content.strip_prefix("0::").ok_or_else(|| {
100            io::Error::new(io::ErrorKind::InvalidData, "unexpected cgroup format")
101        })?;
102
103        Ok(cgroup.trim().trim_matches('/').replace('/', "-"))
104    }
105}
106
107/// Aggregated D-Bus resource accounting grouped by cgroup.
108/// Not directly present in dbus-broker stats; computed by summing peer stats that share a cgroup.
109/// Grouping by cgroup reduces metric cardinality while still identifying abusive clients.
110#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
111pub struct DBusBrokerCGroupAccounting {
112    /// Cgroup path with slashes replaced by dashes (e.g. "system.slice-sshd.service")
113    pub name: String,
114
115    // stats (aggregated sums across all peers in this cgroup)
116    /// Total bus name objects held by peers in this cgroup
117    pub name_objects: Option<u32>,
118    /// Total bytes consumed by match rules from peers in this cgroup
119    pub match_bytes: Option<u32>,
120    /// Total match rules registered by peers in this cgroup
121    pub matches: Option<u32>,
122    /// Total pending reply objects from peers in this cgroup
123    pub reply_objects: Option<u32>,
124    /// Total bytes received by peers in this cgroup
125    pub incoming_bytes: Option<u32>,
126    /// Total file descriptors received by peers in this cgroup
127    pub incoming_fds: Option<u32>,
128    /// Total bytes sent by peers in this cgroup
129    pub outgoing_bytes: Option<u32>,
130    /// Total file descriptors sent by peers in this cgroup
131    pub outgoing_fds: Option<u32>,
132    /// Total activation request bytes from peers in this cgroup
133    pub activation_request_bytes: Option<u32>,
134    /// Total activation request file descriptors from peers in this cgroup
135    pub activation_request_fds: Option<u32>,
136}
137
138impl DBusBrokerCGroupAccounting {
139    pub fn combine_with_peer(&mut self, peer: &DBusBrokerPeerAccounting) {
140        fn sum(a: &mut Option<u32>, b: &Option<u32>) {
141            *a = match (a.take(), b) {
142                (Some(x), Some(y)) => Some(x + y),
143                (Some(x), None) => Some(x),
144                (None, Some(y)) => Some(*y),
145                (None, None) => None,
146            };
147        }
148
149        sum(&mut self.name_objects, &peer.name_objects);
150        sum(&mut self.match_bytes, &peer.match_bytes);
151        sum(&mut self.matches, &peer.matches);
152        sum(&mut self.reply_objects, &peer.reply_objects);
153        sum(&mut self.incoming_bytes, &peer.incoming_bytes);
154        sum(&mut self.incoming_fds, &peer.incoming_fds);
155        sum(&mut self.outgoing_bytes, &peer.outgoing_bytes);
156        sum(&mut self.outgoing_fds, &peer.outgoing_fds);
157        sum(
158            &mut self.activation_request_bytes,
159            &peer.activation_request_bytes,
160        );
161        sum(
162            &mut self.activation_request_fds,
163            &peer.activation_request_fds,
164        );
165    }
166}
167
168/// Current/maximum resource pair as reported by dbus-broker's UserAccounting.
169/// Note: dbus-broker stores the current value in inverted form; actual usage = max - cur.
170#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
171pub struct CurMaxPair {
172    /// Remaining quota (inverted: actual usage = max - cur)
173    pub cur: u32,
174    /// Maximum allowed quota for this resource
175    pub max: u32,
176}
177
178impl CurMaxPair {
179    pub fn get_usage(&self) -> u32 {
180        // There is a theoretical possibility of max < cur due to various factors.
181        // I'll leave it for now to avoid premature optimizations.
182        self.max - self.cur
183    }
184}
185
186/// Per-user aggregated D-Bus resource limits and usage from dbus-broker's UserAccounting.
187/// Each entry tracks quota consumption across all connections belonging to a Unix user.
188#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
189pub struct DBusBrokerUserAccounting {
190    /// Unix user ID this accounting entry belongs to
191    pub uid: u32,
192    /// Username resolved from `uid` at parse time; falls back to the numeric UID string if unknown.
193    pub username: String,
194
195    /// Message byte quota: remaining (cur) and maximum (max) allowed bytes across all connections
196    pub bytes: Option<CurMaxPair>,
197    /// File descriptor quota: remaining (cur) and maximum (max) allowed FDs across all connections
198    pub fds: Option<CurMaxPair>,
199    /// Unattributed stale pidfd descriptors held by the system dbus-broker.
200    ///
201    /// dbus-broker exposes these through procfs, not D-Bus UserAccounting. The kernel
202    /// reports dead pidfds as `Pid: -1` in `/proc/<dbus-broker>/fdinfo/<fd>`.
203    /// The stale pidfd no longer exposes the original process credentials through
204    /// procfs, so monitord surfaces the system-broker count on uid 0/root for metric
205    /// compatibility. This does not mean root owns those stale pidfds.
206    pub stale_fds: Option<u32>,
207    /// Match rule quota: remaining (cur) and maximum (max) allowed match rules across all connections
208    pub matches: Option<CurMaxPair>,
209    /// Object quota: remaining (cur) and maximum (max) allowed objects (names, replies) across all connections
210    pub objects: Option<CurMaxPair>,
211    // UserUsage provides detailed breakdown of the aggregated numbers.
212    // However, dbus-broker exposes usage as real values (not inverted, see CurMaxPair).
213}
214
215impl DBusBrokerUserAccounting {
216    fn new(uid: u32) -> Self {
217        let username = match get_user_by_uid(uid) {
218            Some(user) => user.name().to_string_lossy().into_owned(),
219            None => uid.to_string(),
220        };
221
222        Self {
223            uid,
224            username,
225            ..Default::default()
226        }
227    }
228}
229
230/// D-Bus daemon/broker statistics from org.freedesktop.DBus.Debug.Stats.
231/// Works with both dbus-daemon and dbus-broker; broker-specific fields are in separate maps.
232#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
233pub struct DBusStats {
234    /// Current D-Bus message serial number (monotonically increasing message counter)
235    pub serial: Option<u32>,
236    /// Number of fully authenticated active D-Bus connections
237    pub active_connections: Option<u32>,
238    /// Number of D-Bus connections still in the authentication handshake phase
239    pub incomplete_connections: Option<u32>,
240    /// Current number of registered bus names (well-known + unique)
241    pub bus_names: Option<u32>,
242    /// Peak (high-water mark) number of bus names ever registered simultaneously
243    pub peak_bus_names: Option<u32>,
244    /// Peak number of bus names registered by a single connection
245    pub peak_bus_names_per_connection: Option<u32>,
246    /// Current number of active signal match rules across all connections
247    pub match_rules: Option<u32>,
248    /// Peak number of match rules ever registered simultaneously
249    pub peak_match_rules: Option<u32>,
250    /// Peak number of match rules registered by a single connection
251    pub peak_match_rules_per_connection: Option<u32>,
252    /// Stale pidfd descriptors held by the system dbus-broker process.
253    pub stale_fds: Option<u32>,
254
255    /// Per-peer resource accounting (dbus-broker only), keyed by unique connection name
256    pub dbus_broker_peer_accounting: Option<HashMap<String, DBusBrokerPeerAccounting>>,
257    /// Per-cgroup resource accounting (dbus-broker only), keyed by cgroup name
258    pub dbus_broker_cgroup_accounting: Option<HashMap<String, DBusBrokerCGroupAccounting>>,
259    /// Per-user resource quota accounting (dbus-broker only), keyed by Unix UID
260    pub dbus_broker_user_accounting: Option<HashMap<u32, DBusBrokerUserAccounting>>,
261}
262
263impl DBusStats {
264    pub fn peer_accounting(&self) -> Option<&HashMap<String, DBusBrokerPeerAccounting>> {
265        self.dbus_broker_peer_accounting.as_ref()
266    }
267
268    pub fn cgroup_accounting(&self) -> Option<&HashMap<String, DBusBrokerCGroupAccounting>> {
269        self.dbus_broker_cgroup_accounting.as_ref()
270    }
271
272    pub fn user_accounting(&self) -> Option<&HashMap<u32, DBusBrokerUserAccounting>> {
273        self.dbus_broker_user_accounting.as_ref()
274    }
275}
276
277fn parse_ppid_from_stat(stat: &str) -> Option<u32> {
278    let (_, after_comm) = stat.rsplit_once(") ")?;
279    let mut fields = after_comm.split_whitespace();
280    let _state = fields.next()?;
281    fields.next()?.parse().ok()
282}
283
284fn proc_cmdline_args(path: &Path) -> io::Result<Vec<String>> {
285    let bytes = fs::read(path)?;
286    Ok(bytes
287        .split(|b| *b == b'\0')
288        .filter(|arg| !arg.is_empty())
289        .map(|arg| String::from_utf8_lossy(arg).into_owned())
290        .collect())
291}
292
293fn proc_pid_dirs(proc_root: &Path) -> io::Result<Vec<(u32, std::path::PathBuf)>> {
294    let mut result = Vec::new();
295    for entry in fs::read_dir(proc_root)? {
296        let entry = entry?;
297        let file_name = entry.file_name();
298        let Some(name) = file_name.to_str() else {
299            continue;
300        };
301        let Ok(pid) = name.parse::<u32>() else {
302            continue;
303        };
304        result.push((pid, entry.path()));
305    }
306    Ok(result)
307}
308
309fn find_system_dbus_broker_pid(proc_root: &Path) -> io::Result<Option<u32>> {
310    let pid_dirs = proc_pid_dirs(proc_root)?;
311    let mut launcher_pids = Vec::new();
312
313    for (pid, path) in &pid_dirs {
314        let args = match proc_cmdline_args(&path.join("cmdline")) {
315            Ok(args) => args,
316            Err(_) => continue,
317        };
318
319        let is_system_launcher = args
320            .first()
321            .map(|arg| arg.ends_with("dbus-broker-launch"))
322            .unwrap_or(false)
323            && (args
324                .windows(2)
325                .any(|window| window[0] == "--scope" && window[1] == "system")
326                || args.iter().any(|arg| arg == "--scope=system"));
327
328        if is_system_launcher {
329            launcher_pids.push(*pid);
330        }
331    }
332
333    for (pid, path) in &pid_dirs {
334        let args = match proc_cmdline_args(&path.join("cmdline")) {
335            Ok(args) => args,
336            Err(_) => continue,
337        };
338
339        let is_broker = args
340            .first()
341            .map(|arg| arg.ends_with("dbus-broker"))
342            .unwrap_or(false);
343        if !is_broker {
344            continue;
345        }
346
347        let stat = match fs::read_to_string(path.join("stat")) {
348            Ok(stat) => stat,
349            Err(_) => continue,
350        };
351        let Some(ppid) = parse_ppid_from_stat(&stat) else {
352            continue;
353        };
354        if launcher_pids.contains(&ppid) {
355            return Ok(Some(*pid));
356        }
357    }
358
359    Ok(None)
360}
361
362fn is_stale_pidfd(fd_path: &Path, fdinfo_path: &Path) -> bool {
363    let Ok(target) = fs::read_link(fd_path) else {
364        return false;
365    };
366    if target != Path::new("anon_inode:[pidfd]") {
367        return false;
368    }
369
370    let Ok(fdinfo) = fs::read_to_string(fdinfo_path) else {
371        return false;
372    };
373    fdinfo.lines().any(|line| {
374        let Some(pid) = line.strip_prefix("Pid:") else {
375            return false;
376        };
377        pid.trim() == "-1"
378    })
379}
380
381fn count_stale_pidfds(proc_root: &Path, pid: u32) -> io::Result<u32> {
382    let fd_dir = proc_root.join(pid.to_string()).join("fd");
383    let fdinfo_dir = proc_root.join(pid.to_string()).join("fdinfo");
384    let mut count: u32 = 0;
385
386    for entry in fs::read_dir(fdinfo_dir)? {
387        let entry = entry?;
388        let fd_name = entry.file_name();
389        let fd_path = fd_dir.join(&fd_name);
390        if is_stale_pidfd(&fd_path, &entry.path()) {
391            count = count.saturating_add(1);
392        }
393    }
394
395    Ok(count)
396}
397
398#[cfg(test)]
399fn collect_system_dbus_broker_stale_fds_from_proc(proc_root: &Path) -> io::Result<Option<u32>> {
400    let Some(pid) = find_system_dbus_broker_pid(proc_root)? else {
401        return Ok(None);
402    };
403
404    Ok(Some(count_stale_pidfds(proc_root, pid)?))
405}
406
407fn collect_system_dbus_broker_stale_fds_with_cache(
408    proc_root: &Path,
409    broker_pid_cache: &Mutex<Option<u32>>,
410) -> io::Result<Option<u32>> {
411    let cached_pid = *broker_pid_cache
412        .lock()
413        .map_err(|_| io::Error::other("dbus-broker pid cache poisoned"))?;
414
415    if let Some(pid) = cached_pid {
416        match count_stale_pidfds(proc_root, pid) {
417            Ok(count) => return Ok(Some(count)),
418            Err(err) if err.kind() == io::ErrorKind::NotFound => {
419                if let Ok(mut cached_pid) = broker_pid_cache.lock() {
420                    if *cached_pid == Some(pid) {
421                        *cached_pid = None;
422                    }
423                }
424            }
425            Err(err) => return Err(err),
426        }
427    }
428
429    let Some(pid) = find_system_dbus_broker_pid(proc_root)? else {
430        return Ok(None);
431    };
432
433    let count = count_stale_pidfds(proc_root, pid)?;
434    let mut cached_pid = broker_pid_cache
435        .lock()
436        .map_err(|_| io::Error::other("dbus-broker pid cache poisoned"))?;
437    *cached_pid = Some(pid);
438
439    Ok(Some(count))
440}
441
442fn is_expected_procfs_error(err: &io::Error) -> bool {
443    matches!(
444        err.kind(),
445        io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
446    )
447}
448
449fn collect_system_dbus_broker_stale_fds() -> Option<u32> {
450    static SYSTEM_DBUS_BROKER_PID: OnceLock<Mutex<Option<u32>>> = OnceLock::new();
451
452    let broker_pid_cache = SYSTEM_DBUS_BROKER_PID.get_or_init(|| Mutex::new(None));
453
454    match collect_system_dbus_broker_stale_fds_with_cache(Path::new("/proc"), broker_pid_cache) {
455        Ok(stale_fds) => stale_fds,
456        Err(err) => {
457            if is_expected_procfs_error(&err) {
458                debug!("could not collect dbus-broker stale fd stats: {}", err);
459            } else {
460                error!("failed to collect dbus-broker stale fd stats: {}", err);
461            }
462            None
463        }
464    }
465}
466
467fn get_u32(dict: &Dict, key: &str) -> Option<u32> {
468    let value_key: Value = key.into();
469    dict.get(&value_key).ok().and_then(|v| match v.flatten() {
470        Some(Value::U32(val)) => Some(*val),
471        _ => None,
472    })
473}
474
475fn get_u32_vec(dict: &Dict, key: &str) -> Option<Vec<u32>> {
476    let value_key: Value = key.into();
477    dict.get(&value_key).ok().and_then(|v| match v.flatten() {
478        Some(Value::Array(array)) => {
479            let vec: Vec<u32> = array
480                .iter()
481                .filter_map(|item| {
482                    if let Value::U32(num) = item {
483                        Some(*num)
484                    } else {
485                        None
486                    }
487                })
488                .collect();
489
490            Some(vec)
491        }
492        _ => None,
493    })
494}
495
496/* Parse DBusBrokerPeerAccounting from OwnedValue.
497 * Expected structure:
498 * struct {
499 *     string ":1.2197907"
500 *     array [
501 *         dict entry(
502 *              string "UnixUserID"
503 *              variant uint32 0
504 *         )
505 *         ... other fields
506 *     ]
507 *     array [
508 *         dict entry(
509 *              string "NameObjects"
510 *              uint32 1
511 *         )
512 *         ... other fields
513 *     ]
514 * }
515 */
516
517fn parse_peer_struct(
518    peer_value: &Value,
519    well_known_to_peer_names: &HashMap<String, String>,
520) -> Option<DBusBrokerPeerAccounting> {
521    let peer_struct = match peer_value {
522        Value::Structure(peer_struct) => peer_struct,
523        _ => return None,
524    };
525
526    match peer_struct.fields() {
527        [Value::Str(id), Value::Dict(credentials), Value::Dict(stats), ..] => {
528            Some(DBusBrokerPeerAccounting {
529                id: id.to_string(),
530                well_known_name: well_known_to_peer_names.get(id.as_str()).cloned(),
531                unix_user_id: get_u32(credentials, "UnixUserID"),
532                process_id: get_u32(credentials, "ProcessID"),
533                unix_group_ids: get_u32_vec(credentials, "UnixGroupIDs"),
534                name_objects: get_u32(stats, "NameObjects"),
535                match_bytes: get_u32(stats, "MatchBytes"),
536                matches: get_u32(stats, "Matches"),
537                reply_objects: get_u32(stats, "ReplyObjects"),
538                incoming_bytes: get_u32(stats, "IncomingBytes"),
539                incoming_fds: get_u32(stats, "IncomingFds"),
540                outgoing_bytes: get_u32(stats, "OutgoingBytes"),
541                outgoing_fds: get_u32(stats, "OutgoingFds"),
542                activation_request_bytes: get_u32(stats, "ActivationRequestBytes"),
543                activation_request_fds: get_u32(stats, "ActivationRequestFds"),
544            })
545        }
546        _ => None,
547    }
548}
549
550async fn parse_peer_accounting(
551    connection: &zbus::Connection,
552    config: &crate::config::Config,
553    owned_value: Option<&OwnedValue>,
554) -> Result<Option<Vec<DBusBrokerPeerAccounting>>, MonitordDbusStatsError> {
555    // need to keep collecting peer stats when cgroup_stats=true
556    // since cgroup_stats is a derivative of peer stats
557    if !config.dbus_stats.peer_stats && !config.dbus_stats.cgroup_stats {
558        return Ok(None);
559    }
560
561    let value: &Value = match owned_value {
562        Some(v) => v,
563        None => return Ok(None),
564    };
565
566    let peers_value = match value {
567        Value::Array(peers_value) => peers_value,
568        _ => return Ok(None),
569    };
570
571    let well_known_to_peer_names = get_well_known_to_peer_names(connection, config).await?;
572
573    let result = peers_value
574        .iter()
575        .filter_map(|peer| parse_peer_struct(peer, &well_known_to_peer_names))
576        .collect();
577
578    Ok(Some(result))
579}
580
581fn filter_and_collect_peer_accounting(
582    config: &crate::config::Config,
583    peers: Option<&Vec<DBusBrokerPeerAccounting>>,
584) -> Option<HashMap<String, DBusBrokerPeerAccounting>> {
585    // reject collecting peer stats when told so
586    if !config.dbus_stats.peer_stats {
587        return None;
588    }
589
590    let result = peers?
591        .iter()
592        .filter(|peer| {
593            if config.dbus_stats.peer_well_known_names_only && !peer.has_well_known_name() {
594                return false;
595            }
596
597            let id = peer.id.as_str();
598            let name = peer.get_name();
599            if config.dbus_stats.peer_blocklist.contains(id)
600                || config.dbus_stats.peer_blocklist.contains(name)
601            {
602                return false;
603            }
604
605            if !config.dbus_stats.peer_allowlist.is_empty()
606                && !config.dbus_stats.peer_allowlist.contains(id)
607                && !config.dbus_stats.peer_allowlist.contains(name)
608            {
609                return false;
610            }
611
612            true
613        })
614        .map(|peer| (peer.id.clone(), peer.clone()))
615        .collect();
616
617    Some(result)
618}
619
620fn filter_and_collect_cgroup_accounting(
621    config: &crate::config::Config,
622    peers: Option<&Vec<DBusBrokerPeerAccounting>>,
623) -> Option<HashMap<String, DBusBrokerCGroupAccounting>> {
624    // reject collecting cgroup stats when told so
625    if !config.dbus_stats.cgroup_stats {
626        return None;
627    }
628
629    let mut result: HashMap<String, DBusBrokerCGroupAccounting> = HashMap::new();
630
631    for peer in peers?.iter() {
632        let cgroup_name = match peer.get_cgroup_name() {
633            Ok(name) => name,
634            Err(err) => {
635                error!("Failed to get cgroup name for peer {}: {}", peer.id, err);
636                continue;
637            }
638        };
639
640        if config.dbus_stats.cgroup_blocklist.contains(&cgroup_name) {
641            continue;
642        }
643
644        if !config.dbus_stats.cgroup_allowlist.is_empty()
645            && !config.dbus_stats.cgroup_allowlist.contains(&cgroup_name)
646        {
647            continue;
648        }
649
650        let entry =
651            result
652                .entry(cgroup_name.clone())
653                .or_insert_with(|| DBusBrokerCGroupAccounting {
654                    name: cgroup_name,
655                    ..Default::default()
656                });
657
658        entry.combine_with_peer(peer);
659    }
660
661    Some(result)
662}
663
664/* Parse DBusBrokerUserAccounting from OwnedValue.
665 * Expected structure:
666 * struct {
667 *     uint32 0
668 *     array [
669 *         struct {
670 *             string "Bytes"
671 *             uint32 536843240
672 *             uint32 536870912
673 *         }
674 *         ... more fields
675 *     ]
676 *     # TODO parse usages, ignoring for now
677 *     # see src/bus/driver.c:2258
678 *     # the part below is not parsed
679 *     array [
680 *         dict entry(
681 *             uint32 0
682 *             array [
683 *             dict entry(
684 *                 string "Bytes"
685 *                 uint32 27672
686 *             )
687 *             ... more fields
688 *             ]
689 *         )
690 *     ]
691 * }
692 */
693
694fn parse_user_struct(user_value: &Value) -> Option<DBusBrokerUserAccounting> {
695    let user_struct = match user_value {
696        Value::Structure(user_struct) => user_struct,
697        _ => return None,
698    };
699
700    match user_struct.fields() {
701        [Value::U32(uid), Value::Array(user_stats), ..] => {
702            let mut user = DBusBrokerUserAccounting::new(*uid);
703            for user_stat in user_stats.iter() {
704                if let Value::Structure(user_stat) = user_stat {
705                    if let [Value::Str(name), Value::U32(cur), Value::U32(max), ..] =
706                        user_stat.fields()
707                    {
708                        let pair = CurMaxPair {
709                            cur: *cur,
710                            max: *max,
711                        };
712                        match name.as_str() {
713                            "Bytes" => user.bytes = Some(pair),
714                            "Fds" => user.fds = Some(pair),
715                            "Matches" => user.matches = Some(pair),
716                            "Objects" => user.objects = Some(pair),
717                            _ => {} // ignore other fields
718                        }
719                    }
720                }
721            }
722
723            Some(user)
724        }
725        _ => None,
726    }
727}
728
729fn parse_user_accounting(
730    config: &crate::config::Config,
731    owned_value: &OwnedValue,
732) -> Option<HashMap<u32, DBusBrokerUserAccounting>> {
733    // reject collecting user stats when told so
734    if !config.dbus_stats.user_stats {
735        return None;
736    }
737
738    let value: &Value = owned_value;
739    let users_value = match value {
740        Value::Array(users_value) => users_value,
741        _ => return None,
742    };
743
744    let result = users_value
745        .iter()
746        .filter_map(parse_user_struct)
747        .filter(|user| {
748            let uid = user.uid.to_string();
749            if config.dbus_stats.user_blocklist.contains(&uid)
750                || config.dbus_stats.user_blocklist.contains(&user.username)
751            {
752                return false;
753            }
754
755            if !config.dbus_stats.user_allowlist.is_empty()
756                && !config.dbus_stats.user_allowlist.contains(&uid)
757                && !config.dbus_stats.user_allowlist.contains(&user.username)
758            {
759                return false;
760            }
761
762            true
763        })
764        .map(|user| (user.uid, user))
765        .collect();
766
767    Some(result)
768}
769
770async fn get_well_known_to_peer_names(
771    connection: &zbus::Connection,
772    config: &crate::config::Config,
773) -> Result<HashMap<String, String>, MonitordDbusStatsError> {
774    let dbus_proxy: DBusProxy<'static> = DBusProxy::builder(connection)
775        .cache_properties(zbus::proxy::CacheProperties::No)
776        .build()
777        .await?;
778
779    let dbus_names = dbus_proxy.list_names().await?;
780
781    // Each well-known name's owner is an independent D-Bus round-trip. A
782    // semaphore (rather than an unbounded join) caps how many lookups are in
783    // flight at once: a burst of simultaneous D-Bus calls could itself worsen
784    // host-level IPC contention on hosts where per-call latency is already
785    // elevated — the same failure mode units.rs's per-unit loop guards against.
786    let semaphore = Arc::new(Semaphore::new(
787        config.dbus_stats.peer_name_concurrency.max(1) as usize,
788    ));
789    let parent_span = tracing::Span::current();
790    let mut join_set = tokio::task::JoinSet::new();
791    for owned_busname in dbus_names {
792        if let BusName::WellKnown(_) = &*owned_busname {
793            let dbus_proxy = dbus_proxy.clone();
794            let semaphore = Arc::clone(&semaphore);
795            let parent_span = parent_span.clone();
796            join_set.spawn(async move {
797                let _permit = semaphore
798                    .acquire()
799                    .await
800                    .expect("semaphore closed unexpectedly");
801                let span = tracing::debug_span!(
802                    parent: &parent_span,
803                    "peer_name_lookup",
804                    name = %owned_busname
805                );
806                async {
807                    let owner = dbus_proxy.get_name_owner((&owned_busname).into()).await?;
808                    Ok::<_, MonitordDbusStatsError>((owner.to_string(), owned_busname.to_string()))
809                }
810                .instrument(span)
811                .await
812            });
813        }
814    }
815
816    let mut result = HashMap::new();
817    while let Some(joined) = join_set.join_next().await {
818        let (owner, name) = joined??;
819        result.insert(owner, name);
820    }
821
822    Ok(result)
823}
824
825/// Pull all units from dbus and count how system is setup and behaving
826async fn parse_dbus_stats_inner(
827    config: &crate::config::Config,
828    connection: &zbus::Connection,
829    collect_stale_fds: bool,
830) -> Result<DBusStats, MonitordDbusStatsError> {
831    let stats_proxy = StatsProxy::builder(connection)
832        .cache_properties(zbus::proxy::CacheProperties::No)
833        .build()
834        .await?;
835
836    let stale_fds_task = if collect_stale_fds && config.dbus_stats.stale_fd_stats {
837        Some(tokio::task::spawn_blocking(
838            collect_system_dbus_broker_stale_fds,
839        ))
840    } else {
841        None
842    };
843
844    let stats = stats_proxy.get_stats().await?;
845    let peers = parse_peer_accounting(
846        connection,
847        config,
848        stats.rest().get("org.bus1.DBus.Debug.Stats.PeerAccounting"),
849    )
850    .await?;
851
852    let stale_fds = match stale_fds_task {
853        Some(task) => match task.await {
854            Ok(stale_fds) => stale_fds,
855            Err(err) => {
856                error!("dbus-broker stale fd collection task failed: {}", err);
857                None
858            }
859        },
860        None => None,
861    };
862    let mut dbus_broker_user_accounting = stats
863        .rest()
864        .get("org.bus1.DBus.Debug.Stats.UserAccounting")
865        .map(|user| parse_user_accounting(config, user))
866        .unwrap_or_default();
867
868    if let (Some(stale_fds), Some(user_accounting)) =
869        (stale_fds, dbus_broker_user_accounting.as_mut())
870    {
871        if let Some(root) = user_accounting.get_mut(&0) {
872            root.stale_fds = Some(stale_fds);
873        }
874    }
875
876    let dbus_stats = DBusStats {
877        serial: stats.serial(),
878        active_connections: stats.active_connections(),
879        incomplete_connections: stats.incomplete_connections(),
880        bus_names: stats.bus_names(),
881        peak_bus_names: stats.peak_bus_names(),
882        peak_bus_names_per_connection: stats.peak_bus_names_per_connection(),
883        match_rules: stats.match_rules(),
884        peak_match_rules: stats.peak_match_rules(),
885        peak_match_rules_per_connection: stats.peak_match_rules_per_connection(),
886        stale_fds,
887
888        // attempt to parse dbus-broker specific stats
889        dbus_broker_peer_accounting: filter_and_collect_peer_accounting(config, peers.as_ref()),
890        dbus_broker_cgroup_accounting: filter_and_collect_cgroup_accounting(config, peers.as_ref()),
891        dbus_broker_user_accounting,
892    };
893
894    Ok(dbus_stats)
895}
896
897/// Pull all units from dbus and count how system is setup and behaving
898pub async fn parse_dbus_stats(
899    config: &crate::config::Config,
900    connection: &zbus::Connection,
901) -> Result<DBusStats, MonitordDbusStatsError> {
902    parse_dbus_stats_inner(config, connection, true).await
903}
904
905/// Async wrapper than can update dbus stats when passed a locked struct
906pub async fn update_dbus_stats(
907    config: Arc<crate::config::Config>,
908    connection: zbus::Connection,
909    locked_machine_stats: Arc<RwLock<MachineStats>>,
910) -> anyhow::Result<()> {
911    update_dbus_stats_inner(config, connection, locked_machine_stats, true).await
912}
913
914/// Async wrapper for nested machine/container buses.
915///
916/// Stale pidfd accounting is read from host procfs and currently cannot be
917/// attributed to nested machine D-Bus brokers, so machine stats skip it.
918pub async fn update_machine_dbus_stats(
919    config: Arc<crate::config::Config>,
920    connection: zbus::Connection,
921    locked_machine_stats: Arc<RwLock<MachineStats>>,
922) -> anyhow::Result<()> {
923    update_dbus_stats_inner(config, connection, locked_machine_stats, false).await
924}
925
926async fn update_dbus_stats_inner(
927    config: Arc<crate::config::Config>,
928    connection: zbus::Connection,
929    locked_machine_stats: Arc<RwLock<MachineStats>>,
930    collect_stale_fds: bool,
931) -> anyhow::Result<()> {
932    match parse_dbus_stats_inner(&config, &connection, collect_stale_fds).await {
933        Ok(dbus_stats) => {
934            let mut machine_stats = locked_machine_stats.write().await;
935            machine_stats.dbus_stats = Some(dbus_stats)
936        }
937        Err(err) => error!("dbus stats failed: {:?}", err),
938    }
939    Ok(())
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945    use std::os::unix::fs::symlink;
946    use zvariant::{Array, OwnedValue, Str, Structure, Value};
947
948    fn write_fake_proc_process(proc_root: &Path, pid: u32, ppid: u32, cmdline: &[&str]) {
949        let pid_dir = proc_root.join(pid.to_string());
950        fs::create_dir_all(pid_dir.join("fd")).expect("create fake fd dir");
951        fs::create_dir_all(pid_dir.join("fdinfo")).expect("create fake fdinfo dir");
952
953        let mut cmdline_bytes = Vec::new();
954        for arg in cmdline {
955            cmdline_bytes.extend_from_slice(arg.as_bytes());
956            cmdline_bytes.push(0);
957        }
958        fs::write(pid_dir.join("cmdline"), cmdline_bytes).expect("write fake cmdline");
959        fs::write(
960            pid_dir.join("stat"),
961            format!("{pid} (fake process) S {ppid} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"),
962        )
963        .expect("write fake stat");
964    }
965
966    fn write_fake_fd(proc_root: &Path, pid: u32, fd: u32, target: &str, fdinfo: &str) {
967        let pid_dir = proc_root.join(pid.to_string());
968        symlink(target, pid_dir.join("fd").join(fd.to_string())).expect("create fake fd link");
969        fs::write(pid_dir.join("fdinfo").join(fd.to_string()), fdinfo).expect("write fake fdinfo");
970    }
971
972    #[test]
973    fn test_cur_max_pair_usage() {
974        let p = CurMaxPair { cur: 10, max: 100 };
975        assert_eq!(p.get_usage(), 90);
976    }
977
978    #[test]
979    fn test_parse_ppid_from_stat_handles_comm_with_spaces() {
980        let stat = "42 (dbus broker worker) S 7 0 0 0 0";
981        assert_eq!(parse_ppid_from_stat(stat), Some(7));
982    }
983
984    #[test]
985    fn test_collect_system_dbus_broker_stale_fds_from_proc() {
986        let tempdir = tempfile::tempdir().expect("create tempdir");
987        let proc_root = tempdir.path();
988
989        write_fake_proc_process(
990            proc_root,
991            10,
992            1,
993            &[
994                "/usr/bin/dbus-broker-launch",
995                "--scope",
996                "system",
997                "--audit",
998            ],
999        );
1000        write_fake_proc_process(proc_root, 11, 10, &["dbus-broker", "--log", "10"]);
1001        write_fake_proc_process(
1002            proc_root,
1003            20,
1004            1,
1005            &["/usr/bin/dbus-broker-launch", "--scope", "user"],
1006        );
1007        write_fake_proc_process(proc_root, 21, 20, &["dbus-broker", "--log", "10"]);
1008
1009        write_fake_fd(
1010            proc_root,
1011            11,
1012            0,
1013            "anon_inode:[pidfd]",
1014            "Pid:\t-1\nNSpid:\t-1\n",
1015        );
1016        write_fake_fd(
1017            proc_root,
1018            11,
1019            1,
1020            "anon_inode:[pidfd]",
1021            "Pid:\t123\nNSpid:\t123\n",
1022        );
1023        write_fake_fd(proc_root, 11, 2, "socket:[123]", "scm_fds: 0\n");
1024        write_fake_fd(
1025            proc_root,
1026            21,
1027            0,
1028            "anon_inode:[pidfd]",
1029            "Pid:\t-1\nNSpid:\t-1\n",
1030        );
1031
1032        assert_eq!(
1033            collect_system_dbus_broker_stale_fds_from_proc(proc_root).expect("collect stale fds"),
1034            Some(1)
1035        );
1036    }
1037
1038    #[test]
1039    fn test_combine_with_peer_option_summing() {
1040        let mut cg = DBusBrokerCGroupAccounting {
1041            name: "cg1".to_string(),
1042            name_objects: Some(5),
1043            match_bytes: None,
1044            matches: Some(3),
1045            reply_objects: None,
1046            incoming_bytes: Some(10),
1047            incoming_fds: None,
1048            outgoing_bytes: Some(7),
1049            outgoing_fds: Some(2),
1050            activation_request_bytes: None,
1051            activation_request_fds: Some(1),
1052        };
1053
1054        let peer = DBusBrokerPeerAccounting {
1055            id: ":1.1".to_string(),
1056            well_known_name: Some("com.example".to_string()),
1057            unix_user_id: Some(1000),
1058            process_id: Some(1234),
1059            unix_group_ids: Some(vec![1000]),
1060            name_objects: Some(2),
1061            match_bytes: Some(4),
1062            matches: None,
1063            reply_objects: Some(1),
1064            incoming_bytes: None,
1065            incoming_fds: Some(5),
1066            outgoing_bytes: Some(3),
1067            outgoing_fds: None,
1068            activation_request_bytes: Some(8),
1069            activation_request_fds: None,
1070        };
1071
1072        cg.combine_with_peer(&peer);
1073
1074        assert_eq!(cg.name_objects, Some(7));
1075        assert_eq!(cg.match_bytes, Some(4));
1076        assert_eq!(cg.matches, Some(3));
1077        assert_eq!(cg.reply_objects, Some(1));
1078        assert_eq!(cg.incoming_bytes, Some(10));
1079        assert_eq!(cg.incoming_fds, Some(5));
1080        assert_eq!(cg.outgoing_bytes, Some(10));
1081        assert_eq!(cg.outgoing_fds, Some(2));
1082        assert_eq!(cg.activation_request_bytes, Some(8));
1083        assert_eq!(cg.activation_request_fds, Some(1));
1084    }
1085
1086    #[test]
1087    fn test_parse_user_accounting_gating_and_parse() {
1088        // When user_stats=false, should return None
1089        let mut cfg = crate::config::Config::default();
1090        cfg.dbus_stats.user_stats = false;
1091        let empty_val = Value::Array(Array::from(Vec::<Value>::new()));
1092        let empty_owned = OwnedValue::try_from(empty_val).expect("owned value conversion");
1093        assert!(parse_user_accounting(&cfg, &empty_owned).is_none());
1094
1095        // When user_stats=true, empty array should return Some(empty map)
1096        cfg.dbus_stats.user_stats = true;
1097        let empty_val = Value::Array(Array::from(Vec::<Value>::new()));
1098        let owned = OwnedValue::try_from(empty_val).expect("should convert empty array");
1099        let parsed = parse_user_accounting(&cfg, &owned).expect("should parse empty");
1100        assert_eq!(parsed.len(), 0);
1101
1102        // Non-array input should return None
1103        let non_array = OwnedValue::try_from(Value::U32(0)).expect("should convert u32 value");
1104        assert!(parse_user_accounting(&cfg, &non_array).is_none());
1105    }
1106
1107    #[test]
1108    fn test_parse_user_struct_invalid_returns_none() {
1109        // Build an invalid structure (wrong field types/order) to ensure None is returned
1110        let invalid = Value::Structure(Structure::from((
1111            Value::Str(Str::from_static("not_uid")),
1112            Value::U32(10),
1113            Value::U32(20),
1114        )));
1115        assert!(parse_user_struct(&invalid).is_none());
1116    }
1117
1118    #[test]
1119    fn test_user_username_fallback() {
1120        // Use a likely-nonexistent uid to force fallback to stringified uid
1121        let mut user = DBusBrokerUserAccounting::new(999_999);
1122        user.bytes = Some(CurMaxPair { cur: 5, max: 10 });
1123        // If users crate can’t resolve uid, it should fallback to uid string
1124        assert_eq!(&user.username, "999999");
1125    }
1126}